[ADD] screens: tags on the card, table goals, priority editor, import

The operator now reaches tags, goals and priorities from the screens.
A person's card adds and removes tags, the participant list carries a
tag legend; each table takes an evening goal or one per round with its
rule; the generation settings hold a keyboard-operable priority editor
(move, join, split, restore, weights), the two optional criteria and the
goal mode; the proposals table and its ranking sentence follow the
order; the import screen reads a participants or a goals file. Removing
a tag or a goal returns focus to a neighbouring control.
Checked: node 4590, navigateur 942, node-long 124; captures republished.

--- FR ---

[ADD] écrans : étiquettes, objectifs des tables, priorités, import

L'opérateur atteint étiquettes, objectifs et priorités depuis les écrans.
La fiche ajoute et retire les étiquettes, la liste porte leur légende ;
chaque table reçoit un objectif de soirée ou par tour avec sa règle ; les
réglages de génération portent un éditeur des priorités utilisable au
clavier (monter, joindre, séparer, rétablir, poids), les deux critères
facultatifs et le mode des objectifs ; le tableau des propositions et sa
phrase suivent l'ordre ; l'import lit un fichier de personnes ou
d'objectifs. Retirer une étiquette ou un objectif rend le focus à côté.
Vérifié : node 4590, navigateur 942, longues 124 ; captures republiées.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-11 17:53:27 -04:00
parent 7dc23946c2
commit 91879ea4a5
70 changed files with 6525 additions and 555 deletions

View file

@ -539,7 +539,11 @@ réglage du poste, pour la séance, automatique par défaut (étape 9).
| Nouveaux voisins à chaque tour | Génération | cochée |
| Nouvelle table à chaque tour | Génération | cochée |
| Varier les appartenances rencontrées | Génération | cochée |
| Respecter les objectifs des tables | Génération | cochée |
| Rencontrer le plus d'animateurs différents | Génération | décochée |
| Attribuer les sièges | Génération | décochée |
| Objectifs des tables : chercher d'abord | Génération | le plus de places servies |
| Ordre des priorités | Génération | écart au plafond a priori, puis excédent de collisions, puis collisions cumulées, puis tours hors objectif, puis rencontres répétées et retours, puis animateurs non rencontrés, puis redondance d'appartenance |
| Cible de la somme de redondance | Propositions | 1 000 |
<!-- engendre:fin reglages -->

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 102 KiB

File diff suppressed because one or more lines are too long

View file

@ -307,15 +307,22 @@ export function tableau(entetes, lignes) {
/**
* Le tableau des réglages (§ 19.8) : chaque entrée de la définition, dans
* son ordre — son libellé, qui porte l'unité d'un champ, l'écran qui la
* montre, sa valeur par défaut.
* montre, sa valeur par défaut. Celle d'un choix est le libellé de sa valeur,
* <etiquette>.<valeur> ; celle de l'ordre des priorités, ses paliers dans
* l'ordre, séparés par « puis », chaque critère sous son nom complet
* (mobilier.critere.<nom>), ceux d'un palier partagé joints par « avec ».
*
* @param {ReadonlyArray<import('../src/interface/generation/reglages.js').EntreeReglage>} [definition]
* @returns {string}
*/
export function tableauReglages(definition = REGLAGES_GENERATION) {
const defaut = ({ genre, defaut: valeur, cle }) => {
const defaut = ({ genre, defaut: valeur, cle, etiquette }) => {
if (genre === 'champ') return nombre(valeur);
if (genre === 'bascule') return valeur ? 'cochée' : 'décochée';
if (genre === 'choix') return t(`${etiquette}.${valeur}`);
if (genre === 'ordre') {
return valeur.map((palier) => palier.map(({ critere }) => t(`mobilier.critere.${critere}`)).join(' avec ')).join(', puis ');
}
throw new RangeError(`reglages : genre inconnu ${JSON.stringify(genre)} pour ${cle}`);
};
return tableau(

View file

@ -314,10 +314,28 @@ describe('documentation : les producteurs, chacun sur sa source unique (§ 19.8)
test('reglages : une ligne par entrée de la définition, son libellé, son écran et son défaut ; une entrée ajoutée paraît', () => {
const lignes = lignesDuTableau(tableauReglages());
assert.equal(lignes.length, REGLAGES_GENERATION.length);
// Le défaut écrit de chaque genre : un nombre, une case ; le libellé du
// mode par défaut et les paliers de l'ordre par défaut, écrits en toutes
// lettres.
const ecrit = {
champ: ({ defaut }) => nombre(defaut),
bascule: ({ defaut }) => (defaut ? 'cochée' : 'décochée'),
choix: () => 'le plus de places servies',
ordre: () =>
'écart au plafond a priori, puis excédent de collisions, puis collisions cumulées, puis tours hors objectif, ' +
"puis rencontres répétées et retours, puis animateurs non rencontrés, puis redondance d'appartenance",
};
REGLAGES_GENERATION.forEach((entree, rang) => {
const attendu = entree.genre === 'champ' ? nombre(entree.defaut) : entree.defaut ? 'cochée' : 'décochée';
assert.deepEqual(lignes[rang], [t(entree.etiquette), t(`coquille.section.${entree.section}`), attendu]);
assert.deepEqual(lignes[rang], [t(entree.etiquette), t(`coquille.section.${entree.section}`), ecrit[entree.genre](entree)]);
});
// Sept bascules, le mode et l'ordre (§ 5.3, § 5.7).
assert.equal(REGLAGES_GENERATION.filter(({ genre }) => genre === 'bascule').length, 7);
const ordre = REGLAGES_GENERATION.find(({ genre }) => genre === 'ordre');
const partage = { ...ordre, defaut: [[{ critere: 'excedentCollisions', poids: 2 }, { critere: 'redondance', poids: 3 }], [{ critere: 'collisionsCumulees', poids: 1 }]] };
assert.equal(
lignesDuTableau(tableauReglages([partage]))[0][2],
`${t('mobilier.critere.excedentCollisions')} avec ${t('mobilier.critere.redondance')}, puis ${t('mobilier.critere.collisionsCumulees')}`,
);
const ajoutee = { ...REGLAGES_GENERATION[0], etiquette: 'generation.reglage.graine', defaut: 4_294_967_295 };
const avecAjout = lignesDuTableau(tableauReglages([...REGLAGES_GENERATION, ajoutee]));
assert.deepEqual(avecAjout.at(-1), [t('generation.reglage.graine'), t('coquille.section.generation'), '4\u{202F}294\u{202F}967\u{202F}295']);

View file

@ -49,6 +49,11 @@ import { configurationDe } from './configuration.js';
export { CIBLE_REDONDANCE_PAR_DEFAUT, cibleRedondance };
// Les modes des objectifs, le défaut en tête, et les cases qui font compter
// chaque critère, tels que le registre les tient (contrainte 35) :
// l'interface, qui n'importe pas le moteur, les lit ici.
export { CASES, MODES_OBJECTIFS } from '../moteur/criteres.js';
/**
* @typedef {Object} LigneEvaluation
* @property {number} id celui de la proposition ; retenu.proposition pour le retenu

View file

@ -1245,8 +1245,11 @@ describe("libellés : l'ordre des priorités et le mode des objectifs (§ 5.7,
assert.equal(libelle('POIDS_HORS_DOMAINE', { valeur: 2.5, remede: null }), "Un poids est un nombre entier de 1 à 100 : 2,5 ne l'est pas.");
assert.equal(libelle('POIDS_HORS_DOMAINE', { valeur: Number.NaN, remede: null }), 'Un poids est un nombre entier de 1 à 100.');
const partage = libelle('OBJECTIFS_PALIER_PARTAGE', { mode: 'minimum', palier: 1, remede: null });
for (const motif of [/« le minimum, puis le total »/, /deux valeurs l'une après l'autre/, /« tours hors objectif »/, /palier 2\b/, /séparer/i, /reste celui d'avant/]) {
for (const motif of [/^Ce mode compare deux valeurs l'une après l'autre/, /« tours hors objectif »/, /palier 2\b/, /séparer/i, /reste celui d'avant/]) {
assert.match(partage, motif);
}
// Posé contre le groupe du mode, il ne nomme pas le mode autrement que
// le bouton choisi.
assert.doesNotMatch(partage, /minimum|total/);
});
});

View file

@ -192,12 +192,19 @@ export const TABLE = new Map([
({ valeur }) =>
`Un poids est un nombre entier de 1 à ${POIDS_MAX}` + (Number.isFinite(valeur) ? ` : ${nombreEcrit(valeur)} ne l'est pas.` : '.'),
],
// Le refus se pose contre le groupe du mode, sous le libellé du bouton
// choisi : il dit « ce mode » sans le renommer, l'écran et le journal ne
// le nommant pas pareil (D22). Un mode inconnu lève.
[
'OBJECTIFS_PALIER_PARTAGE',
({ mode, palier }, texte) =>
`Le mode ${cite(nomMode(mode))} compare deux valeurs l'une après l'autre : les ` +
`${cite(texte('mobilier.critere.toursHorsObjectif'))} s'y rangent seuls à leur palier, et partagent le palier ` +
`${numeroDePalier(palier)}. Les séparer d'abord dans l'ordre des priorités : le mode reste celui d'avant.`,
({ mode, palier }, texte) => {
nomMode(mode);
return (
`Ce mode compare deux valeurs l'une après l'autre : les ` +
`${cite(texte('mobilier.critere.toursHorsObjectif'))} s'y rangent seuls à leur palier, et partagent le palier ` +
`${numeroDePalier(palier)}. Les séparer d'abord dans l'ordre des priorités : le mode reste celui d'avant.`
);
},
],
// Le nom complet de chaque critère de l'ordre, tel que l'éditeur l'écrit
// (§ 5.7) : la phrase de l'ordre nomme, elle, la valeur qui classe

View file

@ -16,6 +16,11 @@ import { regleObjectifs } from '../stockage/document.js';
import { lireEtiquettes } from '../stockage/etiquettes.js';
import { refus } from './erreurs.js';
// La règle par défaut des objectifs de l'événement, que le stockage seul lit
// (« ou » quand les réglages n'en portent pas) : l'interface, qui n'importe
// pas le stockage, la lit ici.
export { regleObjectifs };
// Deux chaînes comparées unité UTF-16 par unité : l'ordre où le fichier
// d'état range une liste d'étiquettes (§ 8.9).
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);

View file

@ -281,6 +281,70 @@ describe('la grande démonstration (§ 15.1, § 19.3 capture 10)', () => {
});
});
describe('les planchers des objectifs (§ 5.6, A10, D16, D20)', () => {
// Quarante personnes inventées qui portent « finance », une table de 8 qui
// la vise pour la soirée, quatre tables de 8 sans objectif, quatre tours :
// les planchers valent 128, 8 et 4 (A10). mode : celui des objectifs,
// absent au défaut.
function quaranteFinance(mode = null) {
const charge = creerCharge({ id: 'evt-objectifs', nom: 'Soirée des Merles', siegesParDefaut: 8, tours: 4 });
charge.participants = Array.from({ length: 40 }, (_, p) => ({
id: p + 1,
nom: `Lacasse${p + 1}`,
prenom: 'Ondine',
appartenance: null,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
etiquettes: ['finance'],
}));
charge.tables = [1, 2, 3, 4, 5].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
charge.tables[0].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }];
if (mode !== null) charge.reglages.modeObjectifs = mode;
charge.prochainsIds = { participant: 41, table: 6, proposition: 1 };
return charge;
}
// Les énoncés des objectifs rendus sous la racine : la valeur que porte
// data-plancher-objectifs, et la phrase.
const planchers = (racine) =>
[...racine.querySelectorAll('[data-plancher-objectifs]')].map((li) => [li.dataset.plancherObjectifs, texteDe(li.querySelector('[data-phrase]'))]);
test('sous « le plus de places servies », le seul plancher des tours hors objectif, avec son remède, dans la section et le panneau du plan', async () => {
const m = await ouvrirCharge(quaranteFinance());
m.ui.allerA('diagnostic');
expect(diagnostiquer(configurationDepuisCharge(charge(m))).objectifsInevitables).toEqual({ horsObjectif: 128, jamaisServies: 8, horsObjectifMax: 4 });
await attendre(() =>
expect(planchers(section(m))).toEqual([['toursHorsObjectif', t('diagnostic.objectifs.toursHorsObjectif', { plancher: 128 })]]),
);
const ligne = section(m).querySelector('[data-plancher-objectifs]');
expect(ligne.dataset.enonce).toBe('diagnostic.objectifs.toursHorsObjectif');
expect(texteDe(ligne.querySelector('[data-remede]'))).toBe(t('diagnostic.remede.objectifs'));
expect(visible(ligne.querySelector('[data-phrase]'))).toBe(true);
m.ui.allerA('plan');
await attendre(() => expect(planchers(panneau(m)).map(([grandeur]) => grandeur)).toEqual(['toursHorsObjectif']));
});
test('sous « chacun au moins une fois », les personnes jamais servies d’abord, puis les tours hors objectif', async () => {
const autre = await ouvrirCharge(quaranteFinance('chacun'));
autre.ui.allerA('diagnostic');
await attendre(() =>
expect(planchers(section(autre))).toEqual([
['jamaisServies', t('diagnostic.objectifs.jamaisServies', { plancher: 8 })],
['toursHorsObjectif', t('diagnostic.objectifs.toursHorsObjectif', { plancher: 128 })],
]),
);
});
test('sans objectif, aucun plancher des objectifs ne s’écrit', async () => {
const m = await demonstration('petite-conflit');
m.ui.allerA('diagnostic');
await attendre(() => expect(section(m).querySelectorAll('[data-enonce]').length).toBeGreaterThan(0));
expect(section(m).querySelectorAll('[data-plancher-objectifs]')).toHaveLength(0);
});
});
describe('une seule appartenance (§ 15.4)', () => {
test('la phrase et son remède ; en lecture, refusé avec « Modifier » ; en écriture, exécuté en une entrée', async () => {
const m = await ouvrirCharge(chargeDouze(() => 'Club des Merles'));

View file

@ -25,6 +25,11 @@
// qui l'exécute par la séance ; un refus de la séance — mode lecture, plan
// bloqué — s'affiche à côté du bouton, avec son propre remède.
//
// Les planchers des objectifs (A10) s'énoncent selon le mode réglé : ils
// lisent l'état des critères, criteresDuDiagnostic, qui ne s'évalue que
// lorsqu'un plancher des objectifs est positif. Chacun porte
// data-plancher-objectifs, la valeur dont il donne le plancher.
//
// Une configuration que le moteur refuse n'a pas de diagnostic : son
// code, traduit, se pose à sa place, jamais le message de l'erreur.
import { getContext } from 'svelte';
@ -35,7 +40,7 @@
import Commande from '../primitives/Commande.svelte';
import Pastille from '../primitives/Pastille.svelte';
import Refus from '../primitives/Refus.svelte';
import { cartesDiagnostic, enonces, numeroDeCharge } from './phrases.js';
import { cartesDiagnostic, criteresDuDiagnostic, enonces, numeroDeCharge } from './phrases.js';
let { compact = false } = $props();
@ -49,7 +54,11 @@
const liste = $derived(
resultat === null || resultat.diagnostic === null
? []
: enonces(resultat.diagnostic, { numeroDe: numeroDeCharge(charge), tours: charge.evenement.tours }),
: enonces(resultat.diagnostic, {
numeroDe: numeroDeCharge(charge),
tours: charge.evenement.tours,
criteres: criteresDuDiagnostic(charge, resultat.diagnostic),
}),
);
const cartes = $derived(cartesDiagnostic(liste));
// Le rang de teinte de chaque appartenance, sur tous les participants.
@ -72,7 +81,7 @@
</svelte:element>
<ul aria-labelledby={id}>
{#each carte.enonces as enonce, n (n)}
<li data-enonce={enonce.cle}>
<li data-enonce={enonce.cle} data-plancher-objectifs={enonce.grandeur}>
<p class="enonce"><span data-phrase>{t(enonce.cle, enonce.details)}</span></p>
{#if !compact && enonce.remede !== null}
{@const action = actionDe(enonce.remede)}

View file

@ -16,11 +16,19 @@
// un texte.
//
// L'ordre est celui du § 5.6 — collisions et excédent, redondance,
// animateurs, ancrage, écart d'itinéraire, manque de places —, précédé de ce
// qui vide la séparation de son sens (§ 15.4, § 7.5) et suivi du plafond a
// priori (§ 5.5) et des retours imposés (§ 5.4). Un plancher ne s'énonce que
// strictement positif : un plancher nul laisserait croire qu'un plancher a
// été calculé là où il n'y en a pas.
// animateurs, ancrage, écart d'itinéraire, manque de places, objectifs hors
// d'atteinte —, précédé de ce qui vide la séparation de son sens (§ 15.4,
// § 7.5) et suivi du plafond a priori (§ 5.5) et des retours imposés
// (§ 5.4). Un plancher ne s'énonce que strictement positif : un plancher nul
// laisserait croire qu'un plancher a été calculé là où il n'y en a pas.
//
// Les planchers des objectifs (A10, D16) se calculent toujours ; l'énoncé
// n'écrit que ceux des valeurs que le mode réglé compare, lues dans l'état
// du critère des tours hors objectif (les valeurs de valeursQuiClassent,
// telles qu'evaluer les rend), dans l'ordre où le classement les compare.
// Un critère décoché ou sans objet ne compare rien : ses planchers se
// taisent.
import { evaluer } from '../../application/evaluation.js';
// Désactiver la séparation : le seul remède que réalise une commande. Un
// objet neuf à chaque appel.
@ -127,6 +135,51 @@ function placesManquantes({ placesManquantes: manquantes }) {
return [{ cle: 'diagnostic.placesManquantes', details: { placesManquantes: manquantes }, remede: remede('diagnostic.remede.places') }];
}
// Le nom du critère des objectifs dans l'état des critères, et, pour chaque
// valeur qu'il peut comparer, le champ de objectifsInevitables qui en donne
// le plancher.
const CRITERE_DES_OBJECTIFS = 'toursHorsObjectif';
const PLANCHERS_DES_OBJECTIFS = new Map([
['toursHorsObjectif', 'horsObjectif'],
['jamaisServies', 'jamaisServies'],
['horsObjectifMax', 'horsObjectifMax'],
]);
// Vrai quand un plancher des objectifs au moins est strictement positif.
const unPlancherDesObjectifs = ({ objectifsInevitables }) =>
[...PLANCHERS_DES_OBJECTIFS.values()].some((champ) => objectifsInevitables[champ] > 0);
// Le plancher de chaque valeur que le critère des objectifs compare, quand
// il est strictement positif, dans l'ordre de ses valeurs ; rien quand le
// critère manque à criteres, est décoché ou sans objet. grandeur nomme la
// valeur, que l'écran pose en data-plancher-objectifs.
function objectifs({ objectifsInevitables }, criteres) {
const etat = criteres.find(({ nom }) => nom === CRITERE_DES_OBJECTIFS);
if (etat === undefined || !etat.coche || etat.sansObjet) return [];
return etat.valeurs.flatMap((grandeur) => {
if (!PLANCHERS_DES_OBJECTIFS.has(grandeur)) throw new RangeError(`valeur des objectifs inconnue : ${JSON.stringify(grandeur)}`);
const plancher = objectifsInevitables[PLANCHERS_DES_OBJECTIFS.get(grandeur)];
if (plancher === 0) return [];
return [{ cle: `diagnostic.objectifs.${grandeur}`, details: { plancher }, remede: remede('diagnostic.remede.objectifs'), grandeur }];
});
}
/**
* L'état des critères que l'énoncé des objectifs lit (§ 5.3, D10, D20) :
* celui qu'evaluer rend, sur la charge sans ses placements — aucune
* proposition ne se mesure pour le lire. Quand aucun plancher des objectifs
* n'est positif, il n'y a rien à lire : la liste vide, sans évaluation.
* Ne modifie pas la charge.
*
* @param {Object} charge
* @param {Object} diagnostic celui de diagnostiquerCharge pour cette charge
* @returns {Array<{nom: string, coche: boolean, sansObjet: boolean, valeurs: string[]}>}
*/
export function criteresDuDiagnostic(charge, diagnostic) {
if (!unPlancherDesObjectifs(diagnostic)) return [];
return evaluer({ ...charge, propositions: [], retenu: null }, { plafondsAPriori: null }).criteres;
}
// Le plafond a priori, une ligne par valeur de la répartition ; une
// population vide n'en a aucun, et l'énoncé le dit par le tiret.
function plafond({ plafondAPriori }) {
@ -155,13 +208,16 @@ function imposes({ retoursImposes, ancragesImposantRetours }) {
* le diagnostic.
*
* @param {Object} diagnostic celui que rend diagnostiquerCharge
* @param {{numeroDe: (table: number) => number, tours: number}} options
* @param {{numeroDe: (table: number) => number, tours: number,
* criteres?: Array<{nom: string, coche: boolean, sansObjet: boolean, valeurs: string[]}>}} options
* numeroDe : le numéro affiché d'un identifiant de table ; tours : le
* nombre de tours de l'événement
* nombre de tours de l'événement ; criteres : l'état des critères
* (criteresDuDiagnostic), vide par défaut, et aucun plancher des
* objectifs ne s'écrit alors
* @returns {Array<{cle: string, details: Object, remede: null|{cle: string, details: Object,
* commande?: string, arguments?: Object}}>}
* commande?: string, arguments?: Object}, grandeur?: string}>}
*/
export function enonces(diagnostic, { numeroDe, tours }) {
export function enonces(diagnostic, { numeroDe, tours, criteres = [] }) {
return [
...separation(diagnostic),
...collisions(diagnostic, tours),
@ -170,6 +226,7 @@ export function enonces(diagnostic, { numeroDe, tours }) {
...ancrage(diagnostic, numeroDe),
...ecartItineraire(diagnostic),
...placesManquantes(diagnostic),
...objectifs(diagnostic, criteres),
...plafond(diagnostic),
...imposes(diagnostic),
];

View file

@ -16,7 +16,7 @@ import { texteLivre } from '../../demo/livrees.js';
import { diagnostiquer } from '../../moteur/diagnostic.js';
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { cartesDiagnostic, enonces, numeroDeCharge } from './phrases.js';
import { cartesDiagnostic, criteresDuDiagnostic, enonces, numeroDeCharge } from './phrases.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const TIRET = '\u{2014}';
@ -58,11 +58,28 @@ function chargeDe({ appartenances, capacites, tours = 4, ancres = [], separer =
const fois = (n, valeur) => Array(n).fill(valeur);
// Le diagnostic d'une charge, par l'application, et ses énoncés.
/**
* Une charge d'objectifs (§ 4.5, A10) : n personnes qui portent
* « finance », une table 1 de 8 places qui la vise pour la soirée, puis des
* tables sans objectif aux capacités données, quatre tours. mode : le mode
* des objectifs, absent au défaut ; respecter faux décoche le critère.
*/
function chargeObjectifs(n, autres, { mode = null, respecter = true } = {}) {
const charge = chargeDe({ appartenances: fois(n, null), capacites: [8, ...autres] });
for (const participant of charge.participants) participant.etiquettes = ['finance'];
charge.tables[0].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }];
if (mode !== null) charge.reglages.modeObjectifs = mode;
if (!respecter) charge.reglages.respecterObjectifs = false;
return charge;
}
// Le diagnostic d'une charge, par l'application, et ses énoncés, sous l'état
// des critères que l'écran lit pour les objectifs.
function lire(charge) {
const { diagnostic, erreur } = diagnostiquerCharge(charge);
assert.equal(erreur, null);
return { diagnostic, liste: enonces(diagnostic, { numeroDe: numeroDeCharge(charge), tours: charge.evenement.tours }) };
const criteres = criteresDuDiagnostic(charge, diagnostic);
return { diagnostic, liste: enonces(diagnostic, { numeroDe: numeroDeCharge(charge), tours: charge.evenement.tours, criteres }) };
}
const cles = (liste) => liste.map(({ cle }) => cle);
@ -120,6 +137,9 @@ const CONFIGURATIONS = {
petiteConflit: () => chargeDemo('petite-conflit'),
grande: () => chargeDemo('grande'),
grandeSansException: () => chargeDemo('grande-sans-exception'),
// Dix personnes « finance », une table « finance » de 8, une table de 2
// sans objectif, quatre tours (A10).
objectifs: () => chargeObjectifs(10, [2]),
};
// --- Chaque champ, son énoncé ------------------------------------------------------
@ -239,6 +259,22 @@ const CHAMPS = [
]
: [],
},
{
// Sous le mode par défaut, « le plus de places servies », le seul
// plancher que le mode compare : les tours hors objectif.
champ: 'objectifsInevitables',
attendus: (d) =>
d.objectifsInevitables.horsObjectif > 0
? [
{
cle: 'diagnostic.objectifs.toursHorsObjectif',
details: { plancher: d.objectifsInevitables.horsObjectif },
remede: { cle: 'diagnostic.remede.objectifs', details: {} },
grandeur: 'toursHorsObjectif',
},
]
: [],
},
{
champ: 'plafondAPriori',
attendus: (d) =>
@ -457,6 +493,83 @@ describe('enonces : les phrases du § 5.6', () => {
});
});
describe('enonces : les planchers des objectifs (§ 5.6, A10, D16, D20)', () => {
// Les énoncés des objectifs d'une liste : grandeur, plancher et texte.
const desObjectifs = (liste) =>
liste.filter(({ cle }) => cle.startsWith('diagnostic.objectifs.')).map((enonce) => [enonce.grandeur, enonce.details.plancher, texte(enonce)]);
test('dix personnes « finance », une table de 8, quatre tours : 8 tours hors objectif ; les deux autres planchers, nuls, se taisent sous chaque mode', () => {
const { diagnostic, liste } = lire(chargeObjectifs(10, [2]));
assert.deepEqual(diagnostic.objectifsInevitables, { horsObjectif: 8, jamaisServies: 0, horsObjectifMax: 1 });
assert.deepEqual(desObjectifs(liste), [
[
'toursHorsObjectif',
8,
"au moins 8 tours hors objectif : les tables qui portent un objectif n'ont pas assez de places libres pour chaque tour possible",
],
]);
// Sous « chacun au moins une fois », le plancher des personnes jamais
// servies, nul, ne s'écrit pas.
assert.deepEqual(desObjectifs(lire(chargeObjectifs(10, [2], { mode: 'chacun' })).liste).map(([grandeur]) => grandeur), ['toursHorsObjectif']);
// Sous « l'équité d'abord », le plus de tours hors objectif d'une
// personne vaut 1 : il s'écrit, avant les tours hors objectif.
assert.deepEqual(desObjectifs(lire(chargeObjectifs(10, [2], { mode: 'minimum' })).liste), [
['horsObjectifMax', 1, 'au moins une personne passe au moins 1 tour hors objectif, quel que soit le placement'],
[
'toursHorsObjectif',
8,
"au moins 8 tours hors objectif : les tables qui portent un objectif n'ont pas assez de places libres pour chaque tour possible",
],
]);
});
test('quarante personnes « finance » : 128, 8 et 4 ; chaque mode écrit les planchers qu’il compare, dans l’ordre où il les compare, et jamais les autres', () => {
const autres = fois(4, 8);
const { diagnostic, liste } = lire(chargeObjectifs(40, autres));
assert.deepEqual(diagnostic.objectifsInevitables, { horsObjectif: 128, jamaisServies: 8, horsObjectifMax: 4 });
const grandeurs = (mode) => desObjectifs(lire(chargeObjectifs(40, autres, { mode })).liste).map(([grandeur, plancher]) => [grandeur, plancher]);
assert.deepEqual(desObjectifs(liste).map(([grandeur, plancher]) => [grandeur, plancher]), [['toursHorsObjectif', 128]]);
assert.deepEqual(grandeurs('chacun'), [
['jamaisServies', 8],
['toursHorsObjectif', 128],
]);
assert.deepEqual(grandeurs('minimum'), [
['horsObjectifMax', 4],
['toursHorsObjectif', 128],
]);
const [jamais] = desObjectifs(lire(chargeObjectifs(40, autres, { mode: 'chacun' })).liste);
assert.equal(jamais[2], 'au moins 8 personnes jamais servies par une table à objectif, quel que soit le placement');
for (const enonce of liste.filter(({ cle }) => cle.startsWith('diagnostic.objectifs.'))) {
assert.deepEqual(enonce.remede, { cle: 'diagnostic.remede.objectifs', details: {} });
assert.equal(typeof texte(enonce.remede), 'string');
}
});
test('le critère des objectifs décoché : aucun plancher des objectifs, le diagnostic les calcule toujours', () => {
const { diagnostic, liste } = lire(chargeObjectifs(40, fois(4, 8), { mode: 'chacun', respecter: false }));
assert.equal(diagnostic.objectifsInevitables.horsObjectif, 128);
assert.deepEqual(desObjectifs(liste), []);
});
test('criteresDuDiagnostic : sans plancher positif, aucune lecture des critères ; avec, l’état de chaque critère du registre', () => {
const sans = CONFIGURATIONS.petite();
assert.deepEqual(criteresDuDiagnostic(sans, diagnostiquerCharge(sans).diagnostic), []);
const charge = chargeObjectifs(40, fois(4, 8), { mode: 'minimum' });
const avant = structuredClone(charge);
const criteres = criteresDuDiagnostic(charge, diagnostiquerCharge(charge).diagnostic);
const objectifs = criteres.find(({ nom }) => nom === 'toursHorsObjectif');
assert.deepEqual(objectifs, { nom: 'toursHorsObjectif', coche: true, sansObjet: false, valeurs: ['horsObjectifMax', 'toursHorsObjectif'] });
assert.deepEqual(charge, avant);
});
test('sans état des critères, enonces n’écrit aucun plancher des objectifs', () => {
const charge = chargeObjectifs(40, fois(4, 8));
const { diagnostic } = diagnostiquerCharge(charge);
const liste = enonces(diagnostic, { numeroDe: numeroDeCharge(charge), tours: 4 });
assert.deepEqual(desObjectifs(liste), []);
});
});
describe('enonces : aux bords', () => {
test('un événement neuf, zéro participant, zéro table : aucun plancher, le plafond a priori « — », jamais 0', () => {
const { liste } = lire(chargeDe({ appartenances: [], capacites: [] }));

View file

@ -5,7 +5,9 @@
// moteur de rendu (§ 5.7, § 13.3) : tant que la charge de la séance ne
// change pas, revenir à l'une ou l'autre section ne mesure rien de nouveau ;
// une charge écrite se mesure une fois, et la meilleure proposition annoncée
// la suit.
// la suit. Une charge sans proposition n'a pas de table partagée : l'éditeur
// des priorités y lit l'état des critères par une évaluation à lui, une fois
// par charge écrite.
//
// Le module de l'évaluation est remplacé par ses propres fonctions,
// espionnées (vi.mock, spy) : evaluer garde son effet, et ses appels se
@ -109,8 +111,14 @@ describe('Génération et Propositions relisent une même évaluation (§ 5.7,
const anciens = charge(m).propositions.map(({ id }) => id);
const avantEcriture = appels();
await m.seance.executer('effacerPropositions', {});
flushSync();
expect(appels(), 'la charge sans proposition, évaluée une fois pour l’état des critères').toBe(avantEcriture + 1);
aller(m, 'participants');
aller(m, 'generation');
expect(appels(), 'la charge sans proposition, revisitée').toBe(avantEcriture + 1);
const avantGeneration = appels();
await enregistrer(m, { graine: 9, nombre: 2, arret: 300 });
expect(appels(), 'la charge écrite, mesurée une fois').toBe(avantEcriture + 1);
expect(appels(), 'la charge écrite, mesurée une fois').toBe(avantGeneration + 1);
const attendu = rangUnAttendu(m);
const apres = appels();
expect(identifiant(m).textContent).toBe(attendu);

View file

@ -40,6 +40,7 @@ import { fixerCadre, pointer, touche } from '../../../test/navigateur/entrees.js
import { monterApp } from '../../../test/navigateur/montage.js';
import { composantes, contraste, sousTheme } from '../../../test/navigateur/rendu.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { REGLAGES_GENERATION, valeurDuReglage } from './reglages.js';
import Travailleur from './travailleur.js?worker&inline';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
@ -295,7 +296,7 @@ async function regler(m, reglages) {
describe('les réglages (§ 5.7, § 5.10)', () => {
test('exécuteur direct : quatre champs, chacun son libellé et son unité ; l’historique pré-rempli à 1 000 ; aucun champ de durée', async () => {
const m = await demonstration('petite', { executeur: creerExecuteurDirect() });
const entrees = [...section(m).querySelectorAll('input:not([type="checkbox"])')];
const entrees = [...section(m).querySelectorAll('input:not([type="checkbox"]):not([type="radio"])')];
expect(entrees.length).toBe(4);
expect(entrees.map((entree) => entree.dataset.champ)).toEqual(CHAMPS);
for (const cle of CHAMPS) {
@ -386,24 +387,36 @@ describe('les réglages (§ 5.7, § 5.10)', () => {
});
describe('les contraintes et l’attribution des sièges (§ 5.3)', () => {
// Les cinq bascules, dans l'ordre du § 5.3.
const BASCULES = ['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable', 'varierAppartenances', 'attribuerSieges'];
// Les sept bascules, dans l'ordre du § 5.3 ; les deux cases facultatives
// valent leur défaut tant que la charge ne les porte pas.
const BASCULES = [
'separerAppartenances',
'nouveauxVoisins',
'nouvelleTable',
'varierAppartenances',
'respecterObjectifs',
'rencontrerAnimateurs',
'attribuerSieges',
];
const NOMS_DU_SPEC = [
'Séparer les appartenances',
'Nouveaux voisins à chaque tour',
'Nouvelle table à chaque tour',
'Varier les appartenances rencontrées',
'Respecter les objectifs des tables',
"Rencontrer le plus d'animateurs différents",
'Attribuer les sièges',
];
const valeurLue = (reglages, cle) => valeurDuReglage(reglages, REGLAGES_GENERATION.find((entree) => entree.cle === cle));
test('direct : cinq bascules sous leur nom du § 5.3 ; chacune, une entrée, son réglage seul', async () => {
test('direct : sept bascules sous leur nom du § 5.3 ; chacune, une entrée, son réglage seul', async () => {
const m = await demonstration('petite', { executeur: creerExecuteurDirect() });
const cases = [...section(m).querySelectorAll('input[type="checkbox"]')];
expect(cases.map((caseACocher) => caseACocher.dataset.champ)).toEqual([...BASCULES, 'coeursAutomatiques']);
BASCULES.forEach((cle, rang) => {
expect(etiquette(m, cle).textContent).toBe(t(`generation.contrainte.${cle}`));
expect(t(`generation.contrainte.${cle}`)).toBe(NOMS_DU_SPEC[rang]);
expect(bascule(m, cle).checked, cle).toBe(charge(m).reglages[cle]);
expect(bascule(m, cle).checked, cle).toBe(valeurLue(charge(m).reglages, cle));
});
for (const cle of BASCULES) {
const avant = structuredClone(charge(m).reglages);
@ -412,8 +425,8 @@ describe('les contraintes et l’attribution des sièges (§ 5.3)', () => {
await cliquer(m, bascule(m, cle));
expect(revision(m), cle).toBe(r0 + 1);
expect(journalDe(m).entrees.length, cle).toBe(entrees + 1);
expect(charge(m).reglages, cle).toEqual({ ...avant, [cle]: !avant[cle] });
expect(bascule(m, cle).checked, cle).toBe(!avant[cle]);
expect(charge(m).reglages, cle).toEqual({ ...avant, [cle]: !valeurLue(avant, cle) });
expect(bascule(m, cle).checked, cle).toBe(!valeurLue(avant, cle));
}
// La barre d'espace bascule aussi, par le même geste.
const cle = BASCULES[0];

View file

@ -0,0 +1,257 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// L'éditeur de l'ordre des priorités (§ 5.3, § 5.7, A11, A12) : un groupe
// sous le libellé de l'entrée, la liste ordonnée des paliers (data-priorites),
// chaque palier numéroté en texte, « Palier 2 sur 6 » (data-palier, son
// index à partir de 0), et ses critères (data-priorite, leur nom) sous leur
// nom complet, celui de mobilier.critere.<nom>. L'ordre se lit sur la charge
// par valeurDuReglage, l'ordre par défaut quand elle n'en porte pas.
//
// L'état d'un critère, « décoché » ou « sans objet », s'écrit en texte à
// côté de son nom : il se lit dans etats, l'état que la table d'évaluation
// porte (etatsDesCriteres, priorites.js), jamais recalculé ici. Le critère
// garde son rang dans la liste, quel que soit son état.
//
// Chaque critère porte les boutons de ses gestes offerts (gestesOfferts,
// sous l'admission de la commande sur les réglages de l'événement) ; aucun
// ne nomme une touche, aucun raccourci ne s'y ajoute : Tab les atteint,
// Entrée ou Espace les active. Chaque libellé nomme le critère, ce qui
// donne à chaque bouton un nom accessible à lui. Monter et descendre
// déplacent le palier du critère, et disent « le palier » quand il en
// porte plusieurs. Un geste est
// une commande changerPriorites {paliers} — une entrée du journal — ; abouti,
// la région role="status" annonce la place du critère, et le focus le suit :
// le même bouton quand le geste reste offert, sinon son premier bouton,
// sinon son nom. « Rétablir l'ordre par défaut » est retablirPriorites,
// sans fenêtre à confirmer (D19).
//
// Le refus d'un geste — lecture, plan bloqué, support rompu — se pose
// contre la liste, avec son remède, et décrit le groupe de l'éditeur
// (aria-describedby) ; rien ne s'écrit. Le poids d'un critère d'un palier
// partagé se saisit dans un Champ entier (poids.<nom>), écrit à la sortie
// du champ par changerPriorites ; son refus, un poids hors de son domaine
// compris, se pose contre le champ.
import { getContext, tick } from 'svelte';
import { lireEntier } from '../../application/capacite.js';
import Champ from '../primitives/Champ.svelte';
import Commande from '../primitives/Commande.svelte';
import Refus from '../primitives/Refus.svelte';
import { admissionSous, avecPoids, etatAffiche, gestesOfferts, listeApres, placeDe } from './priorites.js';
import { valeurDuReglage } from './reglages.js';
let { charge, entree, etats = null } = $props();
const t = getContext('t');
const seance = getContext('seance');
const uid = $props.id();
const idRefus = `${uid}-refus`;
// La clé du libellé de chaque geste : celle d'un critère seul, puis celle
// d'un critère d'un palier partagé.
const LIBELLES = new Map([
['monterCritere', ['generation.priorites.monter', 'generation.priorites.monterPalier']],
['descendreCritere', ['generation.priorites.descendre', 'generation.priorites.descendrePalier']],
['joindrePalier', ['generation.priorites.joindre', 'generation.priorites.joindre']],
['separerPalier', ['generation.priorites.separer', 'generation.priorites.separer']],
]);
const libelleDu = (geste, partage) => LIBELLES.get(geste)[partage ? 1 : 0];
const liste = $derived(valeurDuReglage(charge.reglages, entree));
const regle = $derived(charge.reglages[entree.cle] !== undefined);
const admet = $derived(admissionSous(charge.reglages));
// Le refus du dernier geste, posé contre la liste ; l'annonce du dernier
// geste abouti.
let refus = $state.raw(null);
let annonce = $state('');
let racine = $state(null);
// Exécute la commande ; vrai quand elle aboutit. Un refus de la séance se
// pose contre la liste ; une erreur sans code remonte.
async function executer(nom, args) {
refus = null;
annonce = '';
try {
await seance.executer(nom, args);
return true;
} catch (erreur) {
if (typeof erreur?.code !== 'string') throw erreur;
refus = { code: erreur.code, details: erreur.details ?? {} };
return false;
}
}
// Le focus suit le critère : le bouton du même geste, sinon le premier de
// ses boutons, sinon son nom.
function suivre(critere, geste) {
const porteur = racine?.querySelector(`[data-priorite="${critere}"]`) ?? null;
if (porteur === null) return;
const cible =
porteur.querySelector(`button[data-commande="${geste}"]`) ?? porteur.querySelector('button') ?? porteur.querySelector('.nom');
cible.focus();
}
async function deplacer(critere, geste) {
const apres = listeApres(liste, geste, critere);
if (!(await executer('changerPriorites', { paliers: apres }))) return;
const { palier, paliers, avec } = placeDe(apres, critere);
annonce = t('generation.priorites.annonce', { critere, palier: palier + 1, paliers, avec });
await tick();
suivre(critere, geste);
}
async function retablir() {
if (await executer('retablirPriorites', {})) annonce = t('generation.priorites.annonceRetablie');
}
const ecrirePoids = (critere) => (poids) => seance.executer('changerPriorites', { paliers: avecPoids(liste, critere, poids) });
</script>
<fieldset class="priorites" aria-describedby={refus === null ? undefined : idRefus}>
<legend>{t(entree.etiquette)}</legend>
<p class="note">{t('generation.priorites.note')}</p>
<p class="note">{t(regle ? 'generation.priorites.regle' : 'generation.priorites.parDefaut')}</p>
<ol data-priorites bind:this={racine}>
{#each liste as palier, index (palier.map(({ critere }) => critere).join())}
{@const partage = palier.length > 1}
<li data-palier={index}>
<p class="numero">{t('generation.priorites.palier', { palier: index + 1, paliers: liste.length })}</p>
<ul>
{#each palier as { critere, poids } (critere)}
{@const etat = etatAffiche(etats?.get(critere))}
<li data-priorite={critere}>
<span class="nom" tabindex="-1">{t(`mobilier.critere.${critere}`)}</span>
{#if etat !== null}
<span class="etat">{t(`generation.priorites.etat.${etat}`)}</span>
{/if}
{#if partage}
<Champ
cle={`poids.${critere}`}
etiquette="generation.priorites.poids"
details={{ critere }}
valeur={poids}
lire={lireEntier}
executer={ecrirePoids(critere)}
/>
{/if}
<div class="gestes">
{#each gestesOfferts(liste, critere, admet) as geste (geste)}
<Commande
cle={libelleDu(geste, partage)}
details={{ critere }}
nom={geste}
executer={() => deplacer(critere, geste)}
/>
{/each}
</div>
</li>
{/each}
</ul>
</li>
{/each}
</ol>
<p class="annonce" role="status">{annonce}</p>
<div class="retablir">
<Commande cle="generation.priorites.retablir" nom="retablirPriorites" executer={retablir} />
</div>
{#if refus !== null}
<Refus id={idRefus} {refus} lever={() => (refus = null)} />
{/if}
</fieldset>
<style>
/* Le groupe : un filet de carte, l'intertitre en petites capitales d'encre
secondaire, comme les contraintes et les cœurs. */
.priorites {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-2);
margin: 0;
padding: var(--espace-3) var(--espace-4);
border: 1px solid var(--couleur-carte-filet);
border-radius: var(--rayon-2);
}
legend {
padding: 0 var(--espace-1);
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-intertitre);
font-weight: bold;
letter-spacing: var(--interlettre-capitales);
text-transform: uppercase;
}
p {
margin: 0;
}
.note {
color: var(--couleur-page-texte-secondaire);
}
/* Les paliers, l'un sous l'autre, chacun séparé du suivant par un filet ;
son numéro en tête, en texte. */
ol {
display: flex;
flex-direction: column;
align-self: stretch;
margin: 0;
padding: 0;
list-style: none;
}
ol > li {
display: flex;
flex-direction: column;
gap: var(--espace-1);
padding: var(--espace-2) 0;
border-top: 1px solid var(--couleur-ligne-filet);
}
.numero {
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-texte-petit);
font-weight: bold;
}
ul {
display: flex;
flex-direction: column;
gap: var(--espace-2);
margin: 0;
padding: 0 0 0 var(--espace-4);
list-style: none;
}
/* Un critère : son nom, son état, son poids et ses gestes sur une ligne,
qui passe à la suivante quand la place manque. */
ul > li {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--espace-1) var(--espace-3);
}
.nom {
font-weight: bold;
}
.nom:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
.etat {
color: var(--couleur-page-texte-secondaire);
font-style: italic;
}
.gestes {
display: flex;
flex-wrap: wrap;
gap: var(--espace-1);
}
</style>

View file

@ -8,9 +8,16 @@
// entrée de la section 'generation' de REGLAGES_GENERATION dans son ordre,
// sous son libellé, à la valeur de la charge, et aucune d'une autre section ;
// une définition passée par l'épreuve, une entrée ajoutée ou retirée, change
// l'écran de même. Le texte attendu vient de la
// même traduction que celle de l'application ; les noms d'épreuve sont
// inventés.
// l'écran de même.
//
// L'éditeur de l'ordre des priorités et le choix du mode des objectifs
// (§ 5.3, § 5.7, A8, A11, A12, D23) s'éprouvent dans l'application montée
// sur la petite démonstration, la section de la génération ouverte : chaque
// geste, au pointeur réel ou au clavier, est une commande et une entrée ; les
// gestes offerts sont ceux du modèle sous l'admission de la commande ; un
// refus se pose contre la liste ou contre le groupe, et le choix refusé
// revient à l'ancien mode. Le texte attendu vient de la même traduction que
// celle de l'application ; les noms d'épreuve sont inventés.
//
// Le projet navigateur emploie expect, et test et describe de Vitest
// (§ 14.8).
@ -22,12 +29,18 @@ import { creerHorlogePilotee } from '../../application/horloge.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerSeance } from '../../application/seance.js';
import { creerTraduction } from '../../application/traduction.js';
import { CRITERES, MODES_OBJECTIFS } from '../../moteur/criteres.js';
import { lireJournal } from '../../stockage/journal.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js';
import { creerFichiersSimules } from '../../../test/fichiers_simules.js';
import { couleurDeJeton } from '../../../test/navigateur/captures.js';
import { couleurDeJeton, releverTextes } from '../../../test/navigateur/captures.js';
import { fixerCadre, pointer, touche } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import { composantes, sousTheme } from '../../../test/navigateur/rendu.js';
import { TABLES_INTERFACE } from '../libelles.js';
import Reglages from './Reglages.svelte';
import { LISTE_PAR_DEFAUT, admissionSous, etatAffiche, etatsDesCriteres, gestesOfferts, listeApres } from './priorites.js';
import { REGLAGES_GENERATION, reglagesDeSection } from './reglages.js';
// Les entrées que l'écran de la génération montre : celles de sa section.
@ -100,6 +113,18 @@ async function monter({ avecCharge = (charge) => charge, props = {} } = {}) {
// Les désignations des champs montrés, dans l'ordre du document.
const designations = (cible) => [...cible.querySelectorAll('input[data-champ]')].map((entree) => entree.dataset.champ);
// Les désignations qu'une définition montre, dans l'ordre du document : les
// champs, puis chaque bascule suivie des boutons radio des choix qui se
// montrent sous elle ; l'ordre des priorités, à son défaut, n'a aucun champ.
function designationsAttendues(definition) {
const generation = reglagesDeSection(definition, 'generation');
const sous = (cle) => generation.filter((e) => e.genre === 'choix' && e.sous === cle).flatMap((e) => e.valeurs.map(() => e.cle));
return [
...generation.filter(({ genre }) => genre === 'champ').map(({ cle }) => cle),
...generation.filter(({ genre }) => genre === 'bascule').flatMap(({ cle }) => [cle, ...sous(cle)]),
];
}
// L'entrée désignée par sa clé, et le texte de son libellé.
const entree = (cible, cle) => cible.querySelector(`input[data-champ="${cle}"]`);
const libelleDe = (cible, cle) => cible.querySelector(`label[for="${entree(cible, cle).id}"]`).textContent.trim();
@ -109,9 +134,26 @@ describe('Reglages : l’écran montre la définition (§ 19.8)', () => {
const cible = await monter();
expect(DE_LA_GENERATION.length).toBeGreaterThan(0);
expect(DE_LA_GENERATION.length).toBeLessThan(REGLAGES_GENERATION.length);
expect(designations(cible)).toEqual(['graine', ...DE_LA_GENERATION.map(({ cle }) => cle), 'coeursAutomatiques']);
expect(designations(cible)).toEqual(['graine', ...designationsAttendues(REGLAGES_GENERATION), 'coeursAutomatiques']);
expect(cible.querySelector('[data-champ="cibleRedondance"]')).toBeNull();
for (const { cle, etiquette, genre, defaut } of DE_LA_GENERATION) {
for (const { cle, etiquette, genre, defaut, valeurs } of DE_LA_GENERATION) {
if (genre === 'choix') {
const groupe = cible.querySelector('[data-mode-objectifs]');
expect(groupe.querySelector('legend').textContent.trim(), cle).toBe(t(etiquette));
const radios = [...groupe.querySelectorAll(`input[type="radio"][data-champ="${cle}"]`)];
expect(radios.map((r) => r.value)).toEqual([...valeurs]);
for (const radio of radios) {
expect(cible.querySelector(`label[for="${radio.id}"]`).textContent.trim()).toBe(t(`${etiquette}.${radio.value}`));
expect(radio.checked, radio.value).toBe(radio.value === defaut);
}
continue;
}
if (genre === 'ordre') {
const liste = cible.querySelector('[data-priorites]');
expect(liste.closest('fieldset').querySelector('legend').textContent.trim()).toBe(t(etiquette));
expect(liste.querySelectorAll('[data-palier]')).toHaveLength(defaut.length);
continue;
}
expect(libelleDe(cible, cle), cle).toBe(t(etiquette));
// Une charge neuve porte les défauts de la définition.
if (genre === 'bascule') expect(entree(cible, cle).checked, cle).toBe(defaut);
@ -148,8 +190,8 @@ describe('Reglages : l’écran montre la définition (§ 19.8)', () => {
}),
props: { definition },
});
const cles = DE_LA_GENERATION.map(({ cle: c }) => c);
expect(designations(cible)).toEqual(['graine', cles[0], 'generation.epreuve', ...cles.slice(1), 'epreuveBascule', 'coeursAutomatiques']);
const [premier, ...autres] = designationsAttendues(REGLAGES_GENERATION);
expect(designations(cible)).toEqual(['graine', premier, 'generation.epreuve', ...autres, 'epreuveBascule', 'coeursAutomatiques']);
expect(libelleDe(cible, 'generation.epreuve')).toBe(t('generation.coeurs.titre'));
expect(entree(cible, 'generation.epreuve').value).toBe('4321');
expect(libelleDe(cible, 'epreuveBascule')).toBe(t('generation.contraintes.titre'));
@ -179,8 +221,342 @@ describe('l’habillage des réglages', () => {
}
});
}
const legendes = [...cible.querySelectorAll('legend')];
expect(legendes.length).toBe(2);
// Les contraintes, l'ordre des priorités et les cœurs : trois groupes ;
// le choix du mode, sous sa case, se lit comme le libellé d'un champ.
const legendes = [...cible.querySelectorAll('fieldset:not([data-mode-objectifs]) > legend')];
expect(legendes.length).toBe(3);
for (const legende of legendes) expect(getComputedStyle(legende).textTransform).toBe('uppercase');
expect(getComputedStyle(cible.querySelector('[data-mode-objectifs] > legend')).textTransform).toBe('none');
});
});
// --- L'éditeur des priorités et le choix du mode, dans l'application ----------
const [ECART, EXCEDENT, CUMULEES, HORS_OBJECTIF, REPETEES, ANIMATEURS, REDONDANCE] = CRITERES;
// Les montages de l'application, démontés après l'épreuve ; le collecteur
// d'erreurs n'a rien reçu pendant elle.
const applications = [];
afterEach(() => {
const erreurs = applications.flatMap((m) => m.erreurs());
for (const m of applications.splice(0).reverse()) m.demonter();
window.scrollTo(0, 0);
expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]);
});
// L'application sur la petite démonstration, en écriture sauf ecriture
// faux, la section de la génération ouverte.
async function demonstration({ ecriture = true } = {}) {
await fixerCadre(1366, 700);
const m = await monterApp({ executeur: creerExecuteurDirect() });
applications.push(m);
await m.facade.chargerDemonstration('petite');
if (ecriture) await m.seance.passerEnEcriture();
m.ui.allerA('generation');
flushSync();
return m;
}
const section = (m) => m.racine.querySelector('[data-section="generation"]');
const liste = (m) => section(m).querySelector('[data-priorites]');
const editeur = (m) => liste(m).closest('fieldset');
const critereDe = (m, nom) => liste(m).querySelector(`[data-priorite="${nom}"]`);
const bouton = (m, nom, geste) => critereDe(m, nom)?.querySelector(`[data-commande="${geste}"]`) ?? null;
const offerts = (m, nom) => [...critereDe(m, nom).querySelectorAll('button[data-commande]')].map((b) => b.dataset.commande);
const annonce = (m) => editeur(m).querySelector(':scope > [role="status"]').textContent.trim();
const groupe = (m) => section(m).querySelector('[data-mode-objectifs]');
const radio = (m, valeur) => groupe(m).querySelector(`input[type="radio"][value="${valeur}"]`);
const charge = (m) => m.seance.etat().charge;
const revision = (m) => m.seance.etat().evenement.revision;
const journal = (m) => {
const { base } = m.seance.etat().evenement;
return lireJournal(m.fs.contenu('documents', `${base}${SUFFIXES.journal}`)).entrees.length;
};
const ordreDe = (m) => charge(m).reglages.priorites ?? structuredClone(LISTE_PAR_DEFAUT);
// Laisse passer une image, la file de la séance passée, puis le rendu.
async function passer(m) {
await m.seance.formeNommee(null).catch(() => undefined);
await new Promise((resoudre) => requestAnimationFrame(resoudre));
flushSync();
}
// Un clic réel au centre de l'élément, puis la file de la séance passée.
async function cliquer(m, element) {
expect(element, 'élément à cliquer').not.toBeNull();
element.scrollIntoView({ block: 'center' });
const { left, top, width, height } = element.getBoundingClientRect();
await pointer('presser', left + width / 2, top + height / 2);
await pointer('relacher', left + width / 2, top + height / 2);
await passer(m);
}
// Une touche sur l'élément qui a le focus, puis la file passée.
async function presser(m, cle) {
await touche(cle);
await passer(m);
}
describe('l’ordre des priorités : l’éditeur (§ 5.7, A11, A12)', () => {
test('au défaut : une liste ordonnée de sept paliers numérotés en texte, chaque critère sous son nom complet, son état lu dans la table d’évaluation, aucun poids', async () => {
const m = await demonstration();
expect(liste(m).tagName).toBe('OL');
const paliers = [...liste(m).querySelectorAll(':scope > li[data-palier]')];
expect(paliers).toHaveLength(7);
paliers.forEach((palier, rang) => {
expect(palier.dataset.palier).toBe(String(rang));
expect(palier.textContent).toContain(t('generation.priorites.palier', { palier: rang + 1, paliers: 7 }));
});
const etats = etatsDesCriteres(charge(m));
for (const nom of CRITERES) {
expect(critereDe(m, nom).textContent, nom).toContain(t(`mobilier.critere.${nom}`));
const etat = etatAffiche(etats.get(nom));
for (const ecrit of ['decoche', 'sansObjet']) {
expect(critereDe(m, nom).textContent.includes(t(`generation.priorites.etat.${ecrit}`)), `${nom} ${ecrit}`).toBe(etat === ecrit);
}
}
// La petite démonstration ne porte aucun objectif, et sa case des
// animateurs est décochée.
expect(etatAffiche(etats.get(HORS_OBJECTIF))).toBe('sansObjet');
expect(etatAffiche(etats.get(ANIMATEURS))).toBe('decoche');
expect(editeur(m).querySelector('input[data-champ^="poids."]')).toBeNull();
expect(editeur(m).textContent).toContain(t('generation.priorites.parDefaut'));
expect(annonce(m)).toBe('');
});
test('rouge : les gestes offerts sont ceux du modèle sous l’admission de la commande ; ni « joindre » ni « séparer » pour l’écart et les rencontres répétées, ni « joindre » pour le critère qui les suit', async () => {
const m = await demonstration();
const admet = admissionSous(charge(m).reglages);
for (const nom of CRITERES) expect(offerts(m, nom), nom).toEqual(gestesOfferts(ordreDe(m), nom, admet));
for (const nom of [ECART, EXCEDENT, REPETEES, ANIMATEURS]) expect(offerts(m, nom), nom).not.toContain('joindrePalier');
for (const nom of CRITERES) expect(offerts(m, nom), nom).not.toContain('separerPalier');
expect(offerts(m, ECART)).toEqual(['descendreCritere']);
expect(offerts(m, REDONDANCE)).toEqual(['monterCritere', 'joindrePalier']);
// Aucun bouton ne nomme une touche.
for (const b of editeur(m).querySelectorAll('button')) expect(b.textContent).not.toMatch(/Alt|Ctrl|Maj|↑|↓|Entrée/);
});
test('chaque bouton de l’éditeur a un nom accessible à lui, qui nomme le critère qu’il déplace, au défaut comme dans un palier partagé', async () => {
const m = await demonstration();
// Le nom accessible d'un bouton sans aria-label ni aria-labelledby est
// son texte.
const noms = () =>
[...editeur(m).querySelectorAll('button')].map((b) => {
expect(b.hasAttribute('aria-label') || b.hasAttribute('aria-labelledby')).toBe(false);
return b.textContent.trim();
});
const verifier = () => {
const tous = noms();
expect(new Set(tous).size, tous.join(' | ')).toBe(tous.length);
for (const nom of CRITERES) {
for (const b of critereDe(m, nom).querySelectorAll('button[data-commande]')) {
expect(b.textContent, `${nom} ${b.dataset.commande}`).toContain(t(`mobilier.critere.${nom}`));
}
}
};
verifier();
await cliquer(m, bouton(m, REDONDANCE, 'joindrePalier'));
expect(bouton(m, ANIMATEURS, 'monterCritere')).not.toBeNull();
verifier();
});
test('un geste au pointeur : une commande, une entrée ; l’annonce dit le palier ; le focus suit le critère déplacé ; un poids par critère du palier partagé', async () => {
const m = await demonstration();
const [r0, e0] = [revision(m), journal(m)];
const attendue = listeApres(LISTE_PAR_DEFAUT, 'joindrePalier', REDONDANCE);
await cliquer(m, bouton(m, REDONDANCE, 'joindrePalier'));
expect(revision(m)).toBe(r0 + 1);
expect(journal(m)).toBe(e0 + 1);
expect(charge(m).reglages.priorites).toEqual(attendue);
expect(liste(m).querySelectorAll(':scope > li[data-palier]')).toHaveLength(6);
expect(annonce(m)).toBe(t('generation.priorites.annonce', { critere: REDONDANCE, palier: 6, paliers: 6, avec: [ANIMATEURS] }));
expect(critereDe(m, REDONDANCE).contains(document.activeElement)).toBe(true);
expect(editeur(m).textContent).toContain(t('generation.priorites.regle'));
const poids = [...editeur(m).querySelectorAll('input[data-champ^="poids."]')].map((e) => e.dataset.champ);
expect(poids).toEqual([`poids.${ANIMATEURS}`, `poids.${REDONDANCE}`]);
expect(offerts(m, REDONDANCE)).toEqual(['monterCritere', 'separerPalier']);
});
test('au clavier : Entrée sur « Monter », Espace sur « Descendre » ; chaque geste une entrée ; le focus reste sur le bouton du critère déplacé', async () => {
const m = await demonstration();
const e0 = journal(m);
bouton(m, CUMULEES, 'monterCritere').focus();
await presser(m, 'Enter');
expect(charge(m).reglages.priorites).toEqual(listeApres(LISTE_PAR_DEFAUT, 'monterCritere', CUMULEES));
expect(document.activeElement).toBe(bouton(m, CUMULEES, 'monterCritere'));
bouton(m, CUMULEES, 'descendreCritere').focus();
await presser(m, ' ');
expect(charge(m).reglages.priorites).toBeUndefined();
expect(journal(m)).toBe(e0 + 2);
expect(document.activeElement).toBe(bouton(m, CUMULEES, 'descendreCritere'));
expect(annonce(m)).toBe(t('generation.priorites.annonce', { critere: CUMULEES, palier: 3, paliers: 7, avec: [] }));
});
test('le poids d’un critère d’un palier partagé : 0 refusé contre son champ, rien d’écrit ; 5 écrit, une entrée', async () => {
const m = await demonstration();
await cliquer(m, bouton(m, REDONDANCE, 'joindrePalier'));
const champ = () => editeur(m).querySelector(`input[data-champ="poids.${REDONDANCE}"]`);
expect(editeur(m).querySelector(`label[for="${champ().id}"]`).textContent.trim()).toBe(t('generation.priorites.poids', { critere: REDONDANCE }));
const e0 = journal(m);
await cliquer(m, champ());
champ().select();
await touche('0');
await presser(m, 'Tab');
expect(champ().parentElement.querySelector('[data-refus="POIDS_HORS_DOMAINE"]')).not.toBeNull();
expect(journal(m)).toBe(e0);
await cliquer(m, champ());
champ().select();
await touche('5');
await presser(m, 'Enter');
expect(charge(m).reglages.priorites.at(-1)).toEqual([
{ critere: ANIMATEURS, poids: 1 },
{ critere: REDONDANCE, poids: 5 },
]);
expect(journal(m)).toBe(e0 + 1);
});
test('« Rétablir l’ordre par défaut » : sans fenêtre, une entrée, le réglage retiré, annoncé', async () => {
const m = await demonstration();
await cliquer(m, bouton(m, REDONDANCE, 'joindrePalier'));
const e0 = journal(m);
await cliquer(m, editeur(m).querySelector('[data-commande="retablirPriorites"]'));
expect(document.querySelector('dialog[open]')).toBeNull();
expect(Object.hasOwn(charge(m).reglages, 'priorites')).toBe(false);
expect(journal(m)).toBe(e0 + 1);
expect(liste(m).querySelectorAll(':scope > li[data-palier]')).toHaveLength(7);
expect(annonce(m)).toBe(t('generation.priorites.annonceRetablie'));
});
test('en lecture et sur un plan bloqué : le geste refusé contre la liste, avec son remède ; rien d’écrit', async () => {
const m = await demonstration({ ecriture: false });
const ecritures = m.trace.length;
await cliquer(m, bouton(m, REDONDANCE, 'joindrePalier'));
const lecture = editeur(m).querySelector(':scope > [data-refus="MODE_LECTURE"]');
expect(lecture).not.toBeNull();
// Le groupe de l'éditeur est décrit par le refus ; le focus reste sur
// le bouton du geste.
expect(editeur(m).getAttribute('aria-describedby')).toBe(lecture.id);
expect(document.activeElement).toBe(bouton(m, REDONDANCE, 'joindrePalier'));
expect(lecture.querySelector('[data-commande="modifier"]')).not.toBeNull();
expect(Object.hasOwn(charge(m).reglages, 'priorites')).toBe(false);
expect(m.trace.length).toBe(ecritures);
await m.seance.passerEnEcriture();
await m.seance.executer('changerEtat', { etat: 'bloque' });
flushSync();
const ecrituresBloque = m.trace.length;
await cliquer(m, bouton(m, CUMULEES, 'monterCritere'));
expect(editeur(m).querySelector(':scope > [data-refus="LECTURE_SEULE"] [data-commande="debloquer"]')).not.toBeNull();
expect(Object.hasOwn(charge(m).reglages, 'priorites')).toBe(false);
expect(m.trace.length).toBe(ecrituresBloque);
});
});
describe('le mode des objectifs (§ 5.3, A8, A12, D22, D23)', () => {
test('sous la case « Respecter les objectifs des tables » : sa légende, trois boutons radio expliqués, le défaut choisi, l’état du critère écrit à côté', async () => {
const m = await demonstration();
const caseObjectifs = section(m).querySelector('input[type="checkbox"][data-champ="respecterObjectifs"]');
const caseAnimateurs = section(m).querySelector('input[type="checkbox"][data-champ="rencontrerAnimateurs"]');
expect(caseObjectifs.compareDocumentPosition(groupe(m)) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(groupe(m).compareDocumentPosition(caseAnimateurs) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(groupe(m).tagName).toBe('FIELDSET');
expect(groupe(m).querySelector('legend').textContent.trim()).toBe(t('generation.reglage.modeObjectifs'));
for (const valeur of MODES_OBJECTIFS) {
const r = radio(m, valeur);
expect(groupe(m).querySelector(`label[for="${r.id}"]`).textContent.trim()).toBe(t(`generation.reglage.modeObjectifs.${valeur}`));
expect(document.getElementById(r.getAttribute('aria-describedby')).textContent.trim()).toBe(
t(`generation.reglage.modeObjectifs.${valeur}.explication`),
);
expect(r.checked, valeur).toBe(valeur === 'places');
}
expect(groupe(m).textContent).toContain(t('generation.reglage.modeObjectifs.etat.sansObjet'));
});
test('un choix au pointeur, une entrée ; au clavier, la flèche choisit le voisin ; l’état décoché s’écrit sans recalcul', async () => {
const m = await demonstration();
const e0 = journal(m);
await cliquer(m, radio(m, 'chacun'));
expect(charge(m).reglages.modeObjectifs).toBe('chacun');
expect(radio(m, 'chacun').checked).toBe(true);
expect(journal(m)).toBe(e0 + 1);
radio(m, 'chacun').focus();
await presser(m, 'ArrowDown');
expect(charge(m).reglages.modeObjectifs).toBe('minimum');
expect(radio(m, 'minimum').checked).toBe(true);
expect(journal(m)).toBe(e0 + 2);
await cliquer(m, section(m).querySelector('input[type="checkbox"][data-champ="respecterObjectifs"]'));
expect(groupe(m).textContent).toContain(t('generation.reglage.modeObjectifs.etat.decoche'));
// Toujours modifiable, case décochée.
await cliquer(m, radio(m, 'places'));
expect(Object.hasOwn(charge(m).reglages, 'modeObjectifs')).toBe(false);
});
test('rouge : sous « chacun », pas de « joindre » aux tours hors objectif ; joints, « l’équité d’abord » refusé contre le groupe, le choix revenu', async () => {
const m = await demonstration();
await cliquer(m, radio(m, 'chacun'));
expect(offerts(m, HORS_OBJECTIF)).not.toContain('joindrePalier');
await cliquer(m, radio(m, 'places'));
expect(offerts(m, HORS_OBJECTIF)).toContain('joindrePalier');
await cliquer(m, bouton(m, HORS_OBJECTIF, 'joindrePalier'));
const e0 = journal(m);
await cliquer(m, radio(m, 'minimum'));
const refus = groupe(m).querySelector('[data-refus="OBJECTIFS_PALIER_PARTAGE"]');
expect(refus).not.toBeNull();
expect(refus.querySelector(':scope > p').textContent).toBe(t('OBJECTIFS_PALIER_PARTAGE', { mode: 'minimum', palier: 2 }));
// Chaque bouton radio est décrit par le refus, en plus de son
// explication ; le focus reste dans le groupe.
expect(refus.id).not.toBe('');
for (const valeur of MODES_OBJECTIFS) {
expect(radio(m, valeur).getAttribute('aria-describedby').split(' '), valeur).toContain(refus.id);
}
expect(groupe(m).contains(document.activeElement)).toBe(true);
expect(radio(m, 'places').checked).toBe(true);
expect(radio(m, 'minimum').checked).toBe(false);
expect(Object.hasOwn(charge(m).reglages, 'modeObjectifs')).toBe(false);
expect(journal(m)).toBe(e0);
});
test('en lecture : le choix refusé contre le groupe, le mode inchangé, le choix revenu', async () => {
const m = await demonstration({ ecriture: false });
await cliquer(m, radio(m, 'chacun'));
expect(groupe(m).querySelector('[data-refus="MODE_LECTURE"]')).not.toBeNull();
expect(radio(m, 'places').checked).toBe(true);
expect(radio(m, 'chacun').checked).toBe(false);
expect(Object.hasOwn(charge(m).reglages, 'modeObjectifs')).toBe(false);
});
test('sur un plan bloqué : le choix refusé contre le groupe, avec « Débloquer » ; rien d’écrit, le choix revenu', async () => {
const m = await demonstration();
await m.seance.executer('changerEtat', { etat: 'bloque' });
flushSync();
const ecritures = m.trace.length;
await cliquer(m, radio(m, 'chacun'));
expect(groupe(m).querySelector('[data-refus="LECTURE_SEULE"] [data-commande="debloquer"]')).not.toBeNull();
expect(radio(m, 'places').checked).toBe(true);
expect(radio(m, 'chacun').checked).toBe(false);
expect(m.trace.length).toBe(ecritures);
expect(Object.hasOwn(charge(m).reglages, 'modeObjectifs')).toBe(false);
});
});
describe('le contraste de l’éditeur et du choix du mode (§ 14.5)', () => {
test('chaque texte, poids et refus compris, à 4,5:1 au moins dans les deux thèmes, mesuré au rendu', async () => {
const m = await demonstration();
// Un palier partagé, ses poids, et un refus contre le groupe du mode.
await cliquer(m, bouton(m, HORS_OBJECTIF, 'joindrePalier'));
await cliquer(m, radio(m, 'minimum'));
expect(groupe(m).querySelector('[data-refus]')).not.toBeNull();
expect(editeur(m).querySelectorAll('input[data-champ^="poids."]')).toHaveLength(2);
// Une faute est un texte sous 4,5:1 ou sans fond opaque dessous : la
// liste vide porte le seuil.
for (const theme of ['clair', 'sombre']) {
await sousTheme(theme, async () => {
for (const [nom, racine] of [['éditeur', editeur(m)], ['mode', groupe(m)]]) {
const { fautes, mesures } = releverTextes(racine);
expect(mesures, `${theme} ${nom}`).toBeGreaterThan(3);
expect(fautes, `${theme} ${nom}`).toEqual([]);
}
});
}
});
});

View file

@ -27,7 +27,7 @@
// « Générer ». Son refus, refusGraine, se pose contre elle ; une frappe
// appelle saisirGraine, qui le retire.
//
// Les quatre contraintes et l'attribution des sièges (§ 5.3) sont des
// Les six contraintes et l'attribution des sièges (§ 5.3) sont des
// cases à cocher, chacune sous son nom du spec : un clic, ou la barre
// d'espace, exécute modifierReglage {cle, valeur} — une entrée par
// bascule. À l'issue du geste, la case montre la valeur de la charge,
@ -35,6 +35,22 @@
// bloqué, support rompu — se pose à côté de la case, avec son remède, et
// rien ne s'écrit.
//
// Un choix (§ 5.3, A8) se montre sous sa bascule (entree.sous) : un groupe
// data-mode-objectifs, sa légende, un bouton radio par valeur, chacun sous
// son libellé et décrit par son explication (aria-describedby), puis par
// le refus du groupe quand il y en a un. Il reste modifiable quel que soit
// l'état de son critère, que le groupe écrit en texte, « décoché » ou
// « sans objet », tel que la table d'évaluation le porte
// (etatsDesCriteres) : rien ne se recalcule ici. Choisir, au pointeur
// ou aux flèches du clavier, exécute modifierReglage {cle, valeur} — une
// entrée par choix. Un refus — un mode qui compare deux valeurs alors que
// son critère partage un palier (OBJECTIFS_PALIER_PARTAGE), la lecture, un
// plan bloqué — se pose contre le groupe, et le choix revient à la valeur
// de la charge.
//
// L'ordre des priorités (genre 'ordre') suit les contraintes, dans son
// éditeur (Priorites.svelte), qui lit les mêmes états.
//
// Les cœurs de calcul sont un réglage du poste, que le contrôleur de
// génération tient pour la séance du logiciel : ni le fichier ni le journal
// ne les portent, et ils se règlent en lecture comme en écriture. La case
@ -54,6 +70,8 @@
import Champ from '../primitives/Champ.svelte';
import Refus from '../primitives/Refus.svelte';
import { effetDe } from '../raccourcis.js';
import Priorites from './Priorites.svelte';
import { etatAffiche, etatsDesCriteres } from './priorites.js';
import { REGLAGES_GENERATION, reglagesDeSection, reglagesDuGenre, valeurDuReglage } from './reglages.js';
let {
@ -111,6 +129,14 @@
const deLaGeneration = $derived(reglagesDeSection(definition, 'generation'));
const champs = $derived(reglagesDuGenre(deLaGeneration, 'champ'));
const bascules = $derived(reglagesDuGenre(deLaGeneration, 'bascule'));
const choix = $derived(reglagesDuGenre(deLaGeneration, 'choix'));
const ordres = $derived(reglagesDuGenre(deLaGeneration, 'ordre'));
// Les choix qui se montrent sous la bascule de clé cle.
const choixSous = (cle) => choix.filter((entree) => entree.sous === cle);
// L'état de chaque critère, lu dans la table d'évaluation de la charge ;
// null quand la configuration ne se normalise pas.
const etats = $derived(choix.length + ordres.length === 0 ? null : etatsDesCriteres(charge));
// La lecture d'une saisie du réglage nom : la validation du contrôleur sur
// les réglages proposés, ce réglage remplacé par la saisie ; son refus, ou
@ -144,6 +170,28 @@
caseACocher.checked = valeurDuReglage(charge.reglages, entree);
}
}
// Le refus du dernier choix, { cle, refus }, posé contre son groupe.
let refusChoix = $state.raw(null);
// Le choix d'un bouton radio : la séance écrit sa valeur ; à l'issue,
// chaque bouton du groupe montre la valeur de la charge, écrite ou
// inchangée — un choix refusé revient ainsi à l'ancien.
async function choisir(evenement, entree) {
const groupe = evenement.currentTarget.closest('fieldset');
const { value: valeur } = evenement.currentTarget;
const { cle } = entree;
refusChoix = null;
try {
await seance.executer('modifierReglage', { cle, valeur });
} catch (erreur) {
if (typeof erreur?.code !== 'string') throw erreur;
refusChoix = { cle, refus: { code: erreur.code, details: erreur.details ?? {} } };
} finally {
const courante = valeurDuReglage(charge.reglages, entree);
for (const bouton of groupe.querySelectorAll(`input[type="radio"][data-champ="${cle}"]`)) bouton.checked = bouton.value === courante;
}
}
</script>
<div class="reglages" data-reglages>
@ -190,8 +238,43 @@
<Refus refus={refusBascule.refus} lever={() => (refusBascule = null)} />
{/if}
</div>
{#each choixSous(cle) as entreeChoix (entreeChoix.cle)}
{@const etat = etatAffiche(etats?.get(entreeChoix.critere))}
{@const courante = valeurDuReglage(charge.reglages, entreeChoix)}
{@const idRefusChoix = `${uid}-${entreeChoix.cle}-refus`}
{@const refuse = refusChoix !== null && refusChoix.cle === entreeChoix.cle}
<fieldset class="choix" data-mode-objectifs>
<legend>{t(entreeChoix.etiquette)}</legend>
{#each entreeChoix.valeurs as valeur (valeur)}
{@const id = `${uid}-${entreeChoix.cle}-${valeur}`}
<div class="option">
<input
{id}
type="radio"
name={`${uid}-${entreeChoix.cle}`}
value={valeur}
data-champ={entreeChoix.cle}
checked={courante === valeur}
aria-describedby={refuse ? `${id}-explication ${idRefusChoix}` : `${id}-explication`}
onchange={(evenement) => choisir(evenement, entreeChoix)}
/>
<label for={id}>{t(`${entreeChoix.etiquette}.${valeur}`)}</label>
<p class="explication" id={`${id}-explication`}>{t(`${entreeChoix.etiquette}.${valeur}.explication`)}</p>
</div>
{/each}
{#if etat !== null}
<p class="note">{t(`${entreeChoix.etiquette}.etat.${etat}`)}</p>
{/if}
{#if refuse}
<Refus id={idRefusChoix} refus={refusChoix.refus} lever={() => (refusChoix = null)} />
{/if}
</fieldset>
{/each}
{/each}
</fieldset>
{#each ordres as entree (entree.cle)}
<Priorites {charge} {entree} {etats} />
{/each}
<fieldset class="coeurs" data-coeurs>
<legend>{t('generation.coeurs.titre')}</legend>
<div class="bascule">
@ -319,6 +402,52 @@
margin: 0;
}
/* Le choix, sous sa case, sur toute la largeur du groupe : sa légende se
lit comme le libellé d'un champ, chaque bouton suivi de son explication
en encre secondaire. */
.choix {
display: flex;
flex-basis: 100%;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-2);
margin: 0 0 0 var(--espace-5);
padding: 0;
border: none;
}
.choix > legend {
padding: 0;
margin-bottom: var(--espace-2);
color: var(--couleur-page-texte);
font-size: var(--taille-texte);
font-weight: normal;
letter-spacing: normal;
text-transform: none;
}
.option {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 0 var(--espace-1);
}
.option input {
margin: 0;
}
.option input:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
.explication {
grid-column: 2;
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-texte-petit);
}
p {
margin: 0;
}

View file

@ -43,6 +43,11 @@
// section, une seule, sur l'explication de cette colonne, et le quitter la
// referme ; le terme expliqué porte aria-describedby vers elle.
//
// Les réglages (Reglages.svelte) portent le choix du mode des objectifs,
// sous sa case, et l'éditeur de l'ordre des priorités, après les
// contraintes (§ 5.3, § 5.7) : chacun s'écrit par ses commandes, sans que
// la section s'en mêle.
//
// Habillage : la meilleure proposition est une carte à elle, chaque valeur
// sous son intitulé ; les réglages, « Générer » — l'action principale de
// l'écran —, la question, l'avancement et l'issue vivent dans la carte des

View file

@ -0,0 +1,256 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le modèle de l'éditeur de l'ordre des priorités (§ 5.3, § 5.7, A11, A12),
// pur : Priorites.svelte le câble. Une liste de priorités range les sept
// critères en paliers, du premier au dernier : [[{critere, poids}, …], …].
//
// Les faits du registre des critères (contrainte 35) se lisent par
// l'application, que l'interface atteint seule : l'ordre du registre dans la
// table d'évaluation d'un événement neuf, qui porte l'état de chaque critère
// dans cet ordre ; la validité d'une liste sous le mode de l'événement dans
// la commande changerPriorites, qui la juge par le registre. Aucune règle du
// registre ne se récrit ici : un geste s'offre quand la commande admettrait
// sa liste, et l'écart au plafond a priori, les rencontres répétées, ou les
// tours hors objectif sous « chacun » et « minimum », ne partagent ainsi
// jamais un palier par l'éditeur.
//
// Quatre gestes, chacun sur un critère (§ 5.7, A12) :
// monterCritere son palier passe devant celui du dessus, compagnons
// compris ;
// descendreCritere son palier passe derrière celui du dessous ;
// joindrePalier le critère quitte son palier et rejoint celui du
// dessus ; un palier vidé disparaît ;
// separerPalier le critère quitte son palier partagé pour un palier à
// lui, juste dessous.
// Chaque liste écrite range les critères d'un palier dans l'ordre du
// registre, et donne le poids 1 au critère seul à son palier, que l'écran ne
// montre pas : deux listes de même effet s'écrivent pareil (A16). Un critère
// qui rejoint un palier y entre au poids 1.
//
// L'état « décoché » ou « sans objet » d'un critère se lit dans la table
// d'évaluation (evaluer, criteres), qui le tient de la normalisation du
// moteur : l'éditeur ne le recalcule jamais (§ 13.2). Une charge qui porte
// des placements se lit dans la table que partagent les sections
// (evaluation, propositions/modele.js) : l'éditeur ne mesure rien de
// nouveau (§ 13.3).
import { appliquerCommande, chargeNeuve } from '../../application/commandes.js';
import { evaluer } from '../../application/evaluation.js';
import { SIEGES_PAR_DEFAUT, TOURS_PAR_DEFAUT } from '../../application/evenements.js';
import { evaluation, figeePartout } from '../propositions/modele.js';
/** @typedef {Array<Array<{critere: string, poids: number}>>} ListePriorites */
// Un événement neuf, aux réglages d'une charge neuve : le témoin sur lequel
// se lisent l'ordre du registre et l'admission d'une liste.
const TEMOIN = chargeNeuve(
{ nom: 'priorites', siegesParDefaut: SIEGES_PAR_DEFAUT, tours: TOURS_PAR_DEFAUT },
() => 'priorites',
).charge;
/** Les noms des critères, dans l'ordre du registre, tels que la table
* d'évaluation les porte. */
export const CRITERES_DU_REGISTRE = Object.freeze(evaluer(TEMOIN).criteres.map(({ nom }) => nom));
/** L'ordre par défaut (§ 5.7) : chaque critère seul à son palier, au poids
* 1, dans l'ordre du registre. */
export const LISTE_PAR_DEFAUT = Object.freeze(
CRITERES_DU_REGISTRE.map((critere) => Object.freeze([Object.freeze({ critere, poids: 1 })])),
);
/** Les gestes de l'éditeur, dans l'ordre de leurs boutons ; chacun est
* aussi la désignation de son bouton (data-commande). */
export const GESTES = Object.freeze(['monterCritere', 'descendreCritere', 'joindrePalier', 'separerPalier']);
// Le rang d'un critère dans le registre ; un nom inconnu lève.
function rangDe(critere) {
const rang = CRITERES_DU_REGISTRE.indexOf(critere);
if (rang < 0) throw new RangeError(`critère inconnu de l'éditeur : ${JSON.stringify(critere)}`);
return rang;
}
// Une liste rangée : chaque palier dans l'ordre du registre, le poids 1
// pour un critère seul, les paliers vides retirés. Rend une liste neuve.
function ranger(liste) {
return liste
.filter((palier) => palier.length > 0)
.map((palier) => {
const range = [...palier].sort((a, b) => rangDe(a.critere) - rangDe(b.critere));
return range.map(({ critere, poids }) => ({ critere, poids: range.length === 1 ? 1 : poids }));
});
}
// Une copie de la liste, palier par palier, entrée par entrée.
const copier = (liste) => liste.map((palier) => palier.map(({ critere, poids }) => ({ critere, poids })));
// L'index du palier du critère ; un critère que la liste ne porte pas lève.
function palierDe(liste, critere) {
rangDe(critere);
const index = liste.findIndex((palier) => palier.some((entree) => entree.critere === critere));
if (index < 0) throw new RangeError(`critère absent de la liste : ${JSON.stringify(critere)}`);
return index;
}
/**
* La place d'un critère : l'index de son palier, à partir de 0, le nombre
* de paliers et ses compagnons de palier, dans l'ordre de la liste.
*
* @param {ListePriorites} liste
* @param {string} critere
* @returns {{palier: number, paliers: number, avec: string[]}}
* @throws {RangeError} un critère inconnu ou absent de la liste
*/
export function placeDe(liste, critere) {
const palier = palierDe(liste, critere);
const avec = liste[palier].map((entree) => entree.critere).filter((nom) => nom !== critere);
return { palier, paliers: liste.length, avec };
}
/**
* La liste qu'un geste écrit, rangée, ou null quand le geste ne s'applique
* pas à ce critère : monter ou joindre au premier palier, descendre au
* dernier, séparer un critère seul. Ne juge pas la validité de la liste
* sous un mode : gestesOfferts le fait. Ne modifie pas la liste reçue.
*
* @param {ListePriorites} liste une liste complète
* @param {string} geste l'un de GESTES
* @param {string} critere
* @returns {ListePriorites|null}
* @throws {RangeError} un geste inconnu, un critère inconnu ou absent
*/
export function listeApres(liste, geste, critere) {
if (!GESTES.includes(geste)) throw new RangeError(`geste inconnu de l'éditeur : ${JSON.stringify(geste)}`);
const i = palierDe(liste, critere);
const copie = copier(liste);
const echanger = (a, b) => {
[copie[a], copie[b]] = [copie[b], copie[a]];
return ranger(copie);
};
if (geste === 'monterCritere') return i === 0 ? null : echanger(i - 1, i);
if (geste === 'descendreCritere') return i === copie.length - 1 ? null : echanger(i, i + 1);
const sansLui = copie[i].filter((entree) => entree.critere !== critere);
if (geste === 'joindrePalier') {
if (i === 0) return null;
copie[i - 1] = [...copie[i - 1], { critere, poids: 1 }];
copie[i] = sansLui;
return ranger(copie);
}
if (sansLui.length === 0) return null;
copie.splice(i, 1, sansLui, [{ critere, poids: 1 }]);
return ranger(copie);
}
/**
* Les gestes offerts à un critère, dans l'ordre de GESTES : ceux qui
* s'appliquent (listeApres) et dont la liste est admise.
*
* @param {ListePriorites} liste
* @param {string} critere
* @param {(liste: ListePriorites) => boolean} admet admissionSous, ou une
* épreuve qui juge par le registre
* @returns {string[]}
*/
export function gestesOfferts(liste, critere, admet) {
return GESTES.filter((geste) => {
const apres = listeApres(liste, geste, critere);
return apres !== null && admet(apres);
});
}
/**
* La liste où le critère porte ce poids, les autres inchangés. Le poids
* n'est pas jugé ici : la commande refuse un poids hors de son domaine.
*
* @param {ListePriorites} liste
* @param {string} critere
* @param {number} poids
* @returns {ListePriorites}
*/
export function avecPoids(liste, critere, poids) {
palierDe(liste, critere);
return liste.map((palier) =>
palier.map((entree) => ({ critere: entree.critere, poids: entree.critere === critere ? poids : entree.poids })),
);
}
/**
* L'admission d'une liste sous les réglages d'un événement : vrai quand la
* commande changerPriorites l'écrirait — ou la trouverait égale à l'ordre
* courant —, faux quand elle la refuse par PRIORITES_INVALIDES ou
* POIDS_HORS_DOMAINE. La commande s'applique au témoin, un événement neuf
* qui porte une copie de ces réglages : elle juge la liste par le registre,
* sous le mode des objectifs de l'événement, sans toucher à rien d'autre.
* Tout autre refus remonte.
*
* @param {Object} reglages charge.reglages
* @returns {(liste: ListePriorites) => boolean}
*/
export function admissionSous(reglages) {
const temoin = { ...TEMOIN, reglages: structuredClone(reglages) };
return (liste) => {
try {
appliquerCommande('changerPriorites', temoin, { paliers: liste });
return true;
} catch (erreur) {
if (erreur?.code === 'PRIORITES_INVALIDES' || erreur?.code === 'POIDS_HORS_DOMAINE') return false;
if (erreur?.code === 'SANS_EFFET') return true;
throw erreur;
}
};
}
// Les états d'une table, par nom.
const parNom = (criteres) => new Map(criteres.map((etat) => [etat.nom, etat]));
// Les états des charges sans placement déjà lus, par charge : une charge
// figée partout (figeePartout, la règle du cache de la table partagée) ne
// change plus, et revenir à la section ne la relit pas.
const lus = new WeakMap();
// Les états d'une charge sans placement, ou null sur un refus qui porte un
// code.
function etatsSansPlacement(charge) {
try {
return parNom(evaluer({ ...charge, propositions: [], retenu: null }, { plafondsAPriori: null }).criteres);
} catch (erreur) {
if (typeof erreur?.code !== 'string') throw erreur;
return null;
}
}
/**
* L'état de chaque critère de la charge, par nom, tel que la table
* d'évaluation le porte ({nom, coche, sansObjet, valeurs}). Une charge qui
* porte des placements le lit dans la table partagée des sections, mesurée
* une fois ; une charge sans placement, qui n'en a pas, s'évalue sans
* plafond a priori, aucun placement n'étant à mesurer — une fois pour une
* charge figée partout, celle que publie la séance. null quand la
* configuration ne se normalise pas — un refus qui porte un code — : aucun
* état ne s'écrit alors. Toute autre erreur remonte.
*
* @param {Object} charge
* @returns {Map<string, {nom: string, coche: boolean, sansObjet: boolean, valeurs: string[]}>|null}
*/
export function etatsDesCriteres(charge) {
const { table, refus } = evaluation(charge);
if (table !== null) return parNom(table.criteres);
if (refus !== null) return null;
if (lus.has(charge)) return lus.get(charge);
const etats = etatsSansPlacement(charge);
if (figeePartout(charge)) lus.set(charge, etats);
return etats;
}
/**
* Ce que l'écran écrit d'un état : 'decoche' pour un critère que ses cases
* ne cochent pas, 'sansObjet' pour un critère coché que l'événement ne lui
* donne rien à mesurer, null pour un critère qui compte ou un état inconnu.
*
* @param {{coche: boolean, sansObjet: boolean}|undefined} etat
* @returns {'decoche'|'sansObjet'|null}
*/
export function etatAffiche(etat) {
if (etat === undefined) return null;
if (!etat.coche) return 'decoche';
return etat.sansObjet ? 'sansObjet' : null;
}

View file

@ -0,0 +1,323 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du modèle de l'éditeur de l'ordre des priorités (§ 5.3, § 5.7,
// A11, A12, D8, D23) : l'ordre du registre et l'ordre par défaut, lus par
// l'application et égaux à ceux du registre ; les quatre gestes, chacun
// sur la liste qu'il écrit, rangée ; les gestes offerts, exactement ceux
// dont la liste est valide sous le mode — jamais l'écart ni les rencontres
// répétées dans un palier partagé, ni les tours hors objectif sous
// « chacun » et « minimum » — ; l'admission par la commande, égale à la
// validation du registre ; l'état des critères, lu dans la table
// d'évaluation, jamais recalculé — la garde des sources qui le tient vit
// dans test/sources_interface.test.js. Les noms d'épreuve sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { appliquerCommande } from '../../application/commandes.js';
import { evaluer } from '../../application/evaluation.js';
import { CRITERES, MODES_OBJECTIFS, PRIORITES_PAR_DEFAUT, verifierPriorites } from '../../moteur/criteres.js';
import { rechercher } from '../../moteur/recherche.js';
import { configurationDepuisCharge, creerCharge, priorites } from '../../stockage/document.js';
import { VERSION } from '../../version.genere.js';
import {
CRITERES_DU_REGISTRE,
GESTES,
LISTE_PAR_DEFAUT,
admissionSous,
avecPoids,
etatAffiche,
etatsDesCriteres,
gestesOfferts,
listeApres,
placeDe,
} from './priorites.js';
const [ECART, EXCEDENT, CUMULEES, HORS_OBJECTIF, REPETEES, ANIMATEURS, REDONDANCE] = CRITERES;
// Une liste écrite palier par palier : chaque palier, ses critères et leurs
// poids, [nom, poids] ou nom seul au poids 1.
const liste = (...paliers) =>
paliers.map((palier) => palier.map((e) => (Array.isArray(e) ? { critere: e[0], poids: e[1] } : { critere: e, poids: 1 })));
// La liste par défaut, copiée.
const parDefaut = () => structuredClone(LISTE_PAR_DEFAUT);
// L'admission du registre sous un mode, qui sert d'oracle aux épreuves.
const admiseSous = (mode) => (candidate) => verifierPriorites(candidate, mode) === null;
// Les listes atteintes depuis l'ordre par défaut en au plus profondeur
// gestes admis sous le mode, sans doublon, dans l'ordre de leur découverte.
function atteintes(mode, profondeur) {
const vues = new Map([[JSON.stringify(parDefaut()), parDefaut()]]);
let front = [parDefaut()];
for (let pas = 0; pas < profondeur; pas += 1) {
const suivant = [];
for (const courante of front) {
for (const critere of CRITERES) {
for (const geste of GESTES) {
const apres = listeApres(courante, geste, critere);
if (apres === null || !admiseSous(mode)(apres)) continue;
const cle = JSON.stringify(apres);
if (vues.has(cle)) continue;
vues.set(cle, apres);
suivant.push(apres);
}
}
}
front = suivant;
}
return [...vues.values()];
}
describe('l’ordre du registre et l’ordre par défaut, lus par l’application (§ 5.7, D11)', () => {
test('les critères dans l’ordre du registre, et l’ordre par défaut : chacun seul à son palier, au poids 1, figés', () => {
assert.deepEqual([...CRITERES_DU_REGISTRE], [...CRITERES]);
assert.deepEqual(LISTE_PAR_DEFAUT, structuredClone(PRIORITES_PAR_DEFAUT));
assert.deepEqual(LISTE_PAR_DEFAUT, priorites({ reglages: {} }));
assert.ok(Object.isFrozen(CRITERES_DU_REGISTRE) && Object.isFrozen(LISTE_PAR_DEFAUT));
assert.ok(LISTE_PAR_DEFAUT.every((palier) => Object.isFrozen(palier) && palier.every(Object.isFrozen)));
});
test('placeDe : l’index du palier, le nombre de paliers et les compagnons du critère', () => {
const l = liste([ECART], [EXCEDENT, [CUMULEES, 3]], [HORS_OBJECTIF], [REPETEES], [ANIMATEURS, REDONDANCE]);
assert.deepEqual(placeDe(l, CUMULEES), { palier: 1, paliers: 5, avec: [EXCEDENT] });
assert.deepEqual(placeDe(l, REPETEES), { palier: 3, paliers: 5, avec: [] });
assert.throws(() => placeDe(l, 'constructor'), RangeError);
});
});
describe('les quatre gestes : la liste que chacun écrit (§ 5.7, A12)', () => {
test('monter et descendre échangent le palier du critère avec son voisin, compagnons compris ; aux bords, aucun geste', () => {
const l = liste([ECART], [EXCEDENT, [CUMULEES, 3]], [HORS_OBJECTIF], [REPETEES], [ANIMATEURS], [REDONDANCE]);
assert.deepEqual(
listeApres(l, 'monterCritere', CUMULEES),
liste([EXCEDENT, [CUMULEES, 3]], [ECART], [HORS_OBJECTIF], [REPETEES], [ANIMATEURS], [REDONDANCE]),
);
assert.deepEqual(
listeApres(l, 'descendreCritere', HORS_OBJECTIF),
liste([ECART], [EXCEDENT, [CUMULEES, 3]], [REPETEES], [HORS_OBJECTIF], [ANIMATEURS], [REDONDANCE]),
);
assert.equal(listeApres(l, 'monterCritere', ECART), null);
assert.equal(listeApres(l, 'descendreCritere', REDONDANCE), null);
});
test('joindre : le critère rejoint le palier du dessus, rangé dans l’ordre du registre, au poids 1 ; un palier vidé disparaît', () => {
const l = parDefaut();
// La redondance rejoint les animateurs : six paliers, le dernier rangé.
assert.deepEqual(
listeApres(l, 'joindrePalier', REDONDANCE),
liste([ECART], [EXCEDENT], [CUMULEES], [HORS_OBJECTIF], [REPETEES], [ANIMATEURS, REDONDANCE]),
);
// Un critère d'un palier partagé le quitte seul ; son ancien compagnon,
// seul désormais, revient au poids 1.
const partage = liste([ECART], [REPETEES], [EXCEDENT], [[CUMULEES, 4], [HORS_OBJECTIF, 7]], [ANIMATEURS], [REDONDANCE]);
assert.deepEqual(
listeApres(partage, 'joindrePalier', HORS_OBJECTIF),
liste([ECART], [REPETEES], [EXCEDENT, HORS_OBJECTIF], [CUMULEES], [ANIMATEURS], [REDONDANCE]),
);
// Le palier rejoint se range : le critère joint y prend son rang du
// registre, devant un critère qui le suit.
assert.deepEqual(
listeApres(liste([ECART], [REPETEES], [REDONDANCE], [EXCEDENT], [CUMULEES], [HORS_OBJECTIF], [ANIMATEURS]), 'joindrePalier', EXCEDENT),
liste([ECART], [REPETEES], [EXCEDENT, REDONDANCE], [CUMULEES], [HORS_OBJECTIF], [ANIMATEURS]),
);
assert.equal(listeApres(l, 'joindrePalier', ECART), null);
});
test('séparer : le critère quitte son palier partagé pour un palier à lui, juste dessous ; seul, aucun geste', () => {
const partage = liste([ECART], [[EXCEDENT, 2], [CUMULEES, 5], [HORS_OBJECTIF, 9]], [REPETEES], [ANIMATEURS], [REDONDANCE]);
assert.deepEqual(
listeApres(partage, 'separerPalier', CUMULEES),
liste([ECART], [[EXCEDENT, 2], [HORS_OBJECTIF, 9]], [CUMULEES], [REPETEES], [ANIMATEURS], [REDONDANCE]),
);
const deux = liste([ECART], [[EXCEDENT, 2], [CUMULEES, 5]], [HORS_OBJECTIF], [REPETEES], [ANIMATEURS], [REDONDANCE]);
assert.deepEqual(listeApres(deux, 'separerPalier', CUMULEES), parDefaut());
// Séparé, le premier passe sous son ancien compagnon.
assert.deepEqual(
listeApres(deux, 'separerPalier', EXCEDENT),
liste([ECART], [CUMULEES], [EXCEDENT], [HORS_OBJECTIF], [REPETEES], [ANIMATEURS], [REDONDANCE]),
);
assert.equal(listeApres(parDefaut(), 'separerPalier', EXCEDENT), null);
});
test('chaque liste écrite range chaque palier dans l’ordre du registre, poids 1 pour un critère seul ; la liste reçue ne change pas', () => {
const partage = liste([ECART], [[REDONDANCE, 6], [EXCEDENT, 2]], [CUMULEES], [HORS_OBJECTIF], [REPETEES], [ANIMATEURS]);
const avant = structuredClone(partage);
for (const critere of CRITERES) {
for (const geste of GESTES) {
const apres = listeApres(partage, geste, critere);
if (apres === null) continue;
for (const palier of apres) {
const rangs = palier.map(({ critere: nom }) => CRITERES.indexOf(nom));
assert.deepEqual(rangs, [...rangs].sort((a, b) => a - b), `${geste} ${critere}`);
if (palier.length === 1) assert.equal(palier[0].poids, 1, `${geste} ${critere}`);
}
assert.deepEqual(apres.flat().map(({ critere: nom }) => nom).sort(), [...CRITERES].sort());
}
}
assert.deepEqual(partage, avant);
});
test('un geste inconnu ou un critère inconnu lèvent', () => {
assert.throws(() => listeApres(parDefaut(), 'glisserCritere', EXCEDENT), RangeError);
assert.throws(() => listeApres(parDefaut(), 'monterCritere', '__proto__'), RangeError);
});
test('avecPoids : le poids du seul critère nommé change, la liste reçue non', () => {
const l = liste([ECART], [EXCEDENT, [CUMULEES, 3]], [HORS_OBJECTIF], [REPETEES], [ANIMATEURS], [REDONDANCE]);
const avant = structuredClone(l);
assert.deepEqual(avecPoids(l, CUMULEES, 40), liste([ECART], [EXCEDENT, [CUMULEES, 40]], [HORS_OBJECTIF], [REPETEES], [ANIMATEURS], [REDONDANCE]));
assert.deepEqual(l, avant);
});
});
describe('les gestes offerts (§ 5.7, A12, D8, D23)', () => {
test('rouge : « joindre » n’est jamais offert à l’écart, ni aux rencontres répétées, ni au critère qui les suit', () => {
for (const mode of MODES_OBJECTIFS) {
const l = parDefaut();
assert.ok(!gestesOfferts(l, ECART, admiseSous(mode)).includes('joindrePalier'), mode);
// L'excédent suit l'écart : le joindre partagerait le palier de l'écart.
assert.ok(!gestesOfferts(l, EXCEDENT, admiseSous(mode)).includes('joindrePalier'), mode);
assert.ok(!gestesOfferts(l, REPETEES, admiseSous(mode)).includes('joindrePalier'), mode);
assert.ok(!gestesOfferts(l, ANIMATEURS, admiseSous(mode)).includes('joindrePalier'), mode);
}
});
test('rouge : « joindre » n’est pas offert aux tours hors objectif sous « chacun » et « minimum », ni au critère qui les suit ; sous « places », il l’est', () => {
const l = liste([ECART], [EXCEDENT], [CUMULEES], [HORS_OBJECTIF], [ANIMATEURS], [REPETEES], [REDONDANCE]);
for (const mode of ['chacun', 'minimum']) {
assert.ok(!gestesOfferts(l, HORS_OBJECTIF, admiseSous(mode)).includes('joindrePalier'), mode);
assert.ok(!gestesOfferts(l, ANIMATEURS, admiseSous(mode)).includes('joindrePalier'), mode);
}
assert.ok(gestesOfferts(l, HORS_OBJECTIF, admiseSous('places')).includes('joindrePalier'));
assert.ok(gestesOfferts(l, ANIMATEURS, admiseSous('places')).includes('joindrePalier'));
});
test('sous chaque mode, sur chaque liste atteinte en trois gestes, un geste est offert exactement quand il s’applique et que sa liste est valide', () => {
for (const mode of MODES_OBJECTIFS) {
const admet = admiseSous(mode);
const listes = atteintes(mode, 3);
assert.ok(listes.length > 100, `${mode} : ${listes.length} listes`);
for (const courante of listes) {
assert.equal(verifierPriorites(courante, mode), null);
for (const critere of CRITERES) {
const offerts = gestesOfferts(courante, critere, admet);
const attendus = GESTES.filter((geste) => {
const apres = listeApres(courante, geste, critere);
return apres !== null && verifierPriorites(apres, mode) === null;
});
assert.deepEqual(offerts, attendus, `${mode} ${critere} ${JSON.stringify(courante)}`);
}
}
}
});
test('sous chaque mode, sur chaque liste atteinte en deux gestes, un geste offert se défait par son inverse, offert, qui rend la liste de départ', () => {
// L'inverse de chaque geste : monter et descendre l'un de l'autre ;
// séparer par joindre ; joindre par séparer, pour un critère seul à son
// palier — joint depuis un palier partagé, il laisse ses compagnons
// dans un palier à eux, que séparer ne rejoint pas.
const INVERSES = new Map([
['monterCritere', 'descendreCritere'],
['descendreCritere', 'monterCritere'],
['joindrePalier', 'separerPalier'],
['separerPalier', 'joindrePalier'],
]);
let defaits = 0;
for (const mode of MODES_OBJECTIFS) {
const admet = admiseSous(mode);
for (const depart of atteintes(mode, 2)) {
for (const critere of CRITERES) {
const seul = placeDe(depart, critere).avec.length === 0;
for (const geste of gestesOfferts(depart, critere, admet)) {
if (geste === 'joindrePalier' && !seul) continue;
const apres = listeApres(depart, geste, critere);
const inverse = INVERSES.get(geste);
const message = `${mode} ${geste} ${critere} ${JSON.stringify(depart)}`;
assert.ok(gestesOfferts(apres, critere, admet).includes(inverse), message);
assert.deepEqual(listeApres(apres, inverse, critere), depart, message);
defaits += 1;
}
}
}
}
assert.ok(defaits > 1000, `${defaits} gestes défaits`);
// Un poids réglé se garde quand monter puis descendre déplacent le palier.
const l = liste([ECART], [EXCEDENT, [CUMULEES, 3]], [HORS_OBJECTIF], [REPETEES], [ANIMATEURS], [REDONDANCE]);
assert.deepEqual(listeApres(listeApres(l, 'monterCritere', HORS_OBJECTIF), 'descendreCritere', HORS_OBJECTIF), l);
});
});
describe('l’admission par la commande de l’application (§ 5.9, contrainte 35)', () => {
// Les réglages d'un événement neuf, sous le mode et l'ordre donnés.
const reglagesSous = (mode, ordre) => {
const { reglages } = creerCharge({ id: 'evt-priorites', nom: 'Soirée des Merles', siegesParDefaut: 6, tours: 3 });
if (mode !== 'places') reglages.modeObjectifs = mode;
if (ordre !== null) reglages.priorites = ordre;
return reglages;
};
test('sous chaque mode, la commande admet exactement les listes que le registre valide, sur chaque geste de chaque liste atteinte en un geste', () => {
for (const mode of MODES_OBJECTIFS) {
for (const courante of atteintes(mode, 1)) {
const admet = admissionSous(reglagesSous(mode, courante));
for (const critere of CRITERES) {
for (const geste of GESTES) {
const apres = listeApres(courante, geste, critere);
if (apres === null) continue;
assert.equal(admet(apres), verifierPriorites(apres, mode) === null, `${mode} ${geste} ${critere}`);
}
}
}
}
});
test('un poids hors domaine n’est pas admis ; la liste courante l’est ; les réglages reçus ne changent pas', () => {
const reglages = reglagesSous('places', null);
const avant = structuredClone(reglages);
const admet = admissionSous(reglages);
const deux = listeApres(parDefaut(), 'joindrePalier', REDONDANCE);
assert.equal(admet(avecPoids(deux, REDONDANCE, 0)), false);
assert.equal(admet(avecPoids(deux, REDONDANCE, 101)), false);
assert.equal(admet(avecPoids(deux, REDONDANCE, 100)), true);
assert.equal(admet(parDefaut()), true);
assert.deepEqual(reglages, avant);
});
});
describe('l’état des critères, lu dans la table d’évaluation (§ 5.3, A8, D10)', () => {
// Un événement de trois personnes inventées et une table de six places.
function chargeTrois() {
let charge = creerCharge({ id: 'evt-etat', nom: 'Soirée des Merles', siegesParDefaut: 6, tours: 2 });
for (const [nom, prenom] of [['Ombrelle', 'Iris'], ['Pervenche', 'Théo'], ['Lacasse', 'Ondine']]) {
charge = appliquerCommande('ajouterParticipant', charge, { champs: { nom, prenom } }).charge;
}
return appliquerCommande('creerTable', charge, { forme: 'ronde' }).charge;
}
test('etatsDesCriteres rend, par nom, l’état que la table d’évaluation porte, propositions comprises ou non', () => {
const charge = chargeTrois();
const etats = etatsDesCriteres(charge);
assert.deepEqual([...etats.keys()], [...CRITERES]);
assert.deepEqual([...etats.values()], evaluer(charge).criteres);
assert.equal(etatAffiche(etats.get(HORS_OBJECTIF)), 'sansObjet');
assert.equal(etatAffiche(etats.get(ANIMATEURS)), 'decoche');
assert.equal(etatAffiche(etats.get(EXCEDENT)), null);
const decochee = appliquerCommande('modifierReglage', charge, { cle: 'respecterObjectifs', valeur: false }).charge;
assert.equal(etatAffiche(etatsDesCriteres(decochee).get(HORS_OBJECTIF)), 'decoche');
assert.equal(etatAffiche(undefined), null);
// Avec des propositions : l'état de la table qui les mesure.
const configuration = configurationDepuisCharge(charge);
const propositions = rechercher(configuration, { graine: 3, nombre: 2, arret: 50, historique: 10 });
const generee = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: VERSION.affichee }).charge;
assert.equal(generee.propositions.length, 2);
assert.deepEqual([...etatsDesCriteres(generee).values()], evaluer(generee).criteres);
});
test('une configuration que la mesure refuse, par un code, ne donne aucun état', () => {
const charge = chargeTrois();
charge.reglages.modeObjectifs = 'minimum';
charge.reglages.priorites = liste([ECART], [EXCEDENT], [CUMULEES, HORS_OBJECTIF], [REPETEES], [ANIMATEURS], [REDONDANCE]);
assert.equal(etatsDesCriteres(charge), null);
});
});

View file

@ -7,7 +7,8 @@
// de 'generation' —, et le tableau des réglages de la documentation
// s'engendre de toutes : un réglage ajouté ici paraît des deux côtés.
//
// Une entrée est { nom, cle, etiquette, unite, defaut, genre, section } :
// Une entrée est { nom, cle, etiquette, unite, defaut, genre, section }, et
// une entrée du genre 'choix' porte en plus { valeurs, sous, critere } :
// nom son nom dans charge.reglages, ou dans charge.reglages.generation
// pour un champ de la génération ;
// cle son chemin dans charge.reglages, que la commande qui l'écrit
@ -15,13 +16,24 @@
// generation.<nom> pour un champ de la génération, <nom> sinon ;
// etiquette la clé du libellé, qui porte l'unité pour un champ ;
// unite ce qu'un champ compte, 'propositions' ou 'mouvements' ; null
// pour une bascule et pour la cible, une somme sans unité ;
// pour une bascule, un choix, l'ordre, et pour la cible, une
// somme sans unité ;
// defaut la valeur d'une charge neuve, ou celle d'un réglage facultatif
// que la charge ne porte pas ;
// genre 'champ', une saisie entière, ou 'bascule', une case à cocher ;
// genre 'champ', une saisie entière ; 'bascule', une case à cocher ;
// 'choix', une valeur parmi valeurs, en boutons radio ; 'ordre',
// l'ordre des priorités, une liste de paliers que son éditeur
// règle (Priorites.svelte) ;
// section la section de l'écran qui le montre : 'generation', écrit par
// modifierReglage, ou 'propositions', la cible de la somme de
// redondance, écrite par changerCibleRedondance.
// modifierReglage — l'ordre par changerPriorites —, ou
// 'propositions', la cible de la somme de redondance, écrite par
// changerCibleRedondance ;
// valeurs les valeurs d'un choix, dans l'ordre de ses boutons ; le
// libellé de chacune est <etiquette>.<valeur>, son explication
// <etiquette>.<valeur>.explication ;
// sous la clé de la bascule sous laquelle le choix se montre ;
// critere le critère dont le choix règle la lecture : son état,
// « décoché » ou « sans objet », s'écrit à côté du choix.
//
// La graine, saisie de la commande, et les cœurs, réglage du poste, ne sont
// pas au fichier : Reglages.svelte les montre à part.
@ -29,19 +41,32 @@
// Les défauts se lisent sur la charge d'un événement neuf, telle que la
// commande de création la pose (chargeNeuve) : creerCharge y copie
// GENERATION_PAR_DEFAUT et y écrit les bascules, et l'interface n'importe
// pas le stockage. La charge lue ici n'est écrite nulle part. La cible,
// facultative, n'y est pas : son défaut est CIBLE_REDONDANCE_PAR_DEFAUT.
// pas le stockage. La charge lue ici n'est écrite nulle part. Les réglages
// facultatifs n'y sont pas : la cible a pour défaut
// CIBLE_REDONDANCE_PAR_DEFAUT, l'ordre des priorités LISTE_PAR_DEFAUT, lue
// dans le registre par l'application (priorites.js). Le mode prend ses
// valeurs et son défaut, le premier, dans MODES_OBJECTIFS, et son critère
// dans CASES, celui que sa case fait compter : le registre, que
// l'application réexporte (contrainte 35). Les deux cases portent ici le
// défaut que le stockage leur lit (§ 8.9), et les épreuves de
// reglages.test.js les comparent à ses lecteurs.
import { chargeNeuve } from '../../application/commandes.js';
import { CIBLE_REDONDANCE_PAR_DEFAUT } from '../../application/evaluation.js';
import { CASES, CIBLE_REDONDANCE_PAR_DEFAUT, MODES_OBJECTIFS } from '../../application/evaluation.js';
import { SIEGES_PAR_DEFAUT, TOURS_PAR_DEFAUT } from '../../application/evenements.js';
import { LISTE_PAR_DEFAUT } from './priorites.js';
/** @typedef {'champ'|'bascule'} GenreReglage */
/** @typedef {'champ'|'bascule'|'choix'|'ordre'} GenreReglage */
/** @typedef {'generation'|'propositions'} SectionReglage */
/**
* @typedef {Readonly<{nom: string, cle: string, etiquette: string, unite: 'propositions'|'mouvements'|null,
* defaut: number|boolean, genre: GenreReglage, section: SectionReglage}>} EntreeReglage
* defaut: number|boolean|string|ReadonlyArray<ReadonlyArray<{critere: string, poids: number}>>,
* genre: GenreReglage, section: SectionReglage,
* valeurs?: ReadonlyArray<string>, sous?: string, critere?: string}>} EntreeReglage
*/
// Les genres que valeurDuReglage lit.
const GENRES = Object.freeze(['champ', 'bascule', 'choix', 'ordre']);
const { reglages: DEFAUTS } = chargeNeuve(
{ nom: 'reglages', siegesParDefaut: SIEGES_PAR_DEFAUT, tours: TOURS_PAR_DEFAUT },
() => 'reglages',
@ -58,17 +83,49 @@ const champ = (nom, unite) =>
section: 'generation',
});
const bascule = (nom) =>
// Une bascule ; defaut, pour une case facultative que la charge neuve ne
// porte pas.
const bascule = (nom, defaut = DEFAUTS[nom]) =>
Object.freeze({
nom,
cle: nom,
etiquette: `generation.contrainte.${nom}`,
unite: null,
defaut: DEFAUTS[nom],
defaut,
genre: 'bascule',
section: 'generation',
});
// Le mode des objectifs (§ 5.3, A8, D20) : ce que le critère des tours hors
// objectif cherche d'abord. Le défaut, en tête des modes du registre, est le
// plus de places servies, que le fichier n'écrit pas. Le choix se montre
// sous la case des objectifs, et son critère est celui que cette case fait
// compter.
const SOUS_LE_MODE = 'respecterObjectifs';
const MODE_OBJECTIFS = Object.freeze({
nom: 'modeObjectifs',
cle: 'modeObjectifs',
etiquette: 'generation.reglage.modeObjectifs',
unite: null,
defaut: MODES_OBJECTIFS[0],
genre: 'choix',
section: 'generation',
valeurs: MODES_OBJECTIFS,
sous: SOUS_LE_MODE,
critere: Object.keys(CASES).find((nom) => CASES[nom].includes(SOUS_LE_MODE)),
});
// L'ordre des priorités (§ 5.7, A11) : absent du fichier à l'ordre par défaut.
const PRIORITES = Object.freeze({
nom: 'priorites',
cle: 'priorites',
etiquette: 'generation.reglage.priorites',
unite: null,
defaut: LISTE_PAR_DEFAUT,
genre: 'ordre',
section: 'generation',
});
const CIBLE = Object.freeze({
nom: 'cibleRedondance',
cle: 'cibleRedondance',
@ -81,9 +138,10 @@ const CIBLE = Object.freeze({
/**
* Les réglages de la charge, dans l'ordre des écrans : les trois champs de
* la recherche (§ 5.7, § 5.10), puis les quatre contraintes et l'attribution
* des sièges, dans l'ordre du § 5.3 ; enfin la cible de la somme de
* redondance (§ 8.9), de la section des propositions.
* la recherche (§ 5.7, § 5.10), puis les six contraintes et l'attribution
* des sièges, dans l'ordre du § 5.3 ; le mode des objectifs, que l'écran
* montre sous sa case ; l'ordre des priorités ; enfin la cible de la somme
* de redondance (§ 8.9), de la section des propositions.
*
* @type {ReadonlyArray<EntreeReglage>}
*/
@ -95,7 +153,11 @@ export const REGLAGES_GENERATION = Object.freeze([
bascule('nouveauxVoisins'),
bascule('nouvelleTable'),
bascule('varierAppartenances'),
bascule('respecterObjectifs', true),
bascule('rencontrerAnimateurs', false),
bascule('attribuerSieges'),
MODE_OBJECTIFS,
PRIORITES,
CIBLE,
]);
@ -124,15 +186,17 @@ export function reglagesDeSection(definition, section) {
/**
* La valeur qu'une charge porte pour une entrée, lue au chemin de sa clé :
* reglages.generation[nom] pour un champ de la génération, reglages[nom]
* sinon. Un réglage facultatif absent vaut le défaut de l'entrée.
* sinon — une case, le mode, l'ordre des priorités. Un réglage facultatif
* absent vaut le défaut de l'entrée. Un ordre se rend tel que la charge le
* porte : le stockage le valide à l'écriture comme à la lecture (A16).
*
* @param {Object} reglages charge.reglages
* @param {EntreeReglage} entree
* @returns {number|boolean}
* @returns {EntreeReglage['defaut']}
* @throws {RangeError} pour un genre inconnu
*/
export function valeurDuReglage(reglages, entree) {
if (entree.genre !== 'champ' && entree.genre !== 'bascule') {
if (!GENRES.includes(entree.genre)) {
throw new RangeError(`genre de réglage inconnu : ${JSON.stringify(entree.genre)}`);
}
const valeur = entree.cle.split('.').reduce((objet, nom) => objet?.[nom], reglages);

View file

@ -4,15 +4,26 @@
// Épreuves de la définition des réglages de la charge (§ 5.3, § 5.7,
// § 5.10, § 8.9, § 19.8) : sa forme, figée ; l'ordre des écrans ; les
// défauts, ceux d'une charge neuve, GENERATION_PAR_DEFAUT et
// CIBLE_REDONDANCE_PAR_DEFAUT compris ; chaque réglage du schéma du fichier,
// facultatif compris, et lui seul ; les clés que les commandes acceptent et
// les libellés qui existent ; la lecture d'une valeur dans une charge.
// CIBLE_REDONDANCE_PAR_DEFAUT compris, et ceux que les lecteurs du stockage
// donnent aux réglages facultatifs — les deux cases, le mode des objectifs,
// l'ordre des priorités ; chaque réglage du schéma du fichier, facultatif
// compris, et lui seul ; les clés que les commandes acceptent et les
// libellés qui existent ; la lecture d'une valeur dans une charge.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { appliquerCommande } from '../../application/commandes.js';
import { creerTraduction } from '../../application/traduction.js';
import { CIBLE_REDONDANCE_PAR_DEFAUT, GENERATION_PAR_DEFAUT, SCHEMA, creerCharge } from '../../stockage/document.js';
import { MODES_OBJECTIFS } from '../../moteur/criteres.js';
import {
CIBLE_REDONDANCE_PAR_DEFAUT,
GENERATION_PAR_DEFAUT,
SCHEMA,
creerCharge,
critereCoche,
modeObjectifs,
priorites,
} from '../../stockage/document.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { REGLAGES_GENERATION, reglagesDeSection, reglagesDuGenre, valeurDuReglage } from './reglages.js';
@ -44,30 +55,48 @@ function cheminsDuSchema() {
// n'avoir aucune entrée : l'entrée qui s'ajoute l'en retire.
const SANS_ENTREE = new Map([
['regleObjectifs', 'la règle par défaut des objectifs s’écrit depuis les tables, non depuis la génération'],
['respecterObjectifs', 'case des objectifs, sans entrée à l’écran de la génération'],
['modeObjectifs', 'mode des objectifs, sans entrée à l’écran de la génération'],
['rencontrerAnimateurs', 'case des animateurs, sans entrée à l’écran de la génération'],
['priorites', 'ordre des priorités, écrit par son éditeur de la section Génération, non par une entrée'],
]);
// La commande qui écrit un réglage de chaque section, et ses arguments.
// La commande qui écrit un réglage de chaque section, et ses arguments :
// l'ordre des priorités par changerPriorites, les autres réglages de la
// génération par modifierReglage.
const ECRITURES = new Map([
['generation', (entree, valeur) => ['modifierReglage', { cle: entree.cle, valeur }]],
[
'generation',
(entree, valeur) =>
entree.genre === 'ordre' ? ['changerPriorites', { paliers: valeur }] : ['modifierReglage', { cle: entree.cle, valeur }],
],
['propositions', (entree, valeur) => ['changerCibleRedondance', { cible: String(valeur) }]],
]);
// Une valeur autre que le défaut de l'entrée : la case contraire, un mode
// voisin, un entier de plus, l'ordre par défaut aux deux derniers paliers
// joints.
function autreValeur({ genre, defaut, valeurs }) {
if (genre === 'bascule') return !defaut;
if (genre === 'choix') return valeurs.find((valeur) => valeur !== defaut);
if (genre === 'ordre') return [...defaut.slice(0, -2), [...defaut.at(-2), ...defaut.at(-1)]];
return defaut + 1;
}
// Les clés de chaque entrée, et celles qu'une entrée du genre choix porte en
// plus : ses valeurs, la bascule sous laquelle elle se montre, le critère
// dont elle règle la lecture.
const CLES_D_ENTREE = ['nom', 'cle', 'etiquette', 'unite', 'defaut', 'genre', 'section'];
const CLES_DU_CHOIX = ['valeurs', 'sous', 'critere'];
describe('REGLAGES_GENERATION : la définition que lisent les écrans et la documentation (§ 19.8)', () => {
test('chaque entrée porte nom, cle, etiquette, unite, defaut, genre et section ; la définition comme chaque entrée sont figées', () => {
test('chaque entrée porte nom, cle, etiquette, unite, defaut, genre et section, un choix ses valeurs, sa bascule et son critère ; la définition comme chaque entrée sont figées', () => {
assert.ok(REGLAGES_GENERATION.length > 0, 'définition vide');
assert.ok(Object.isFrozen(REGLAGES_GENERATION));
for (const entree of REGLAGES_GENERATION) {
assert.deepEqual(Object.keys(entree), ['nom', 'cle', 'etiquette', 'unite', 'defaut', 'genre', 'section']);
assert.deepEqual(Object.keys(entree), entree.genre === 'choix' ? [...CLES_D_ENTREE, ...CLES_DU_CHOIX] : CLES_D_ENTREE);
assert.ok(Object.isFrozen(entree), entree.nom);
}
assert.throws(() => REGLAGES_GENERATION.push({}), TypeError);
});
test('l’ordre des écrans : les trois champs de la recherche, les bascules du § 5.3, puis la cible de la section des propositions', () => {
test('l’ordre des écrans : les trois champs de la recherche, les bascules du § 5.3, le mode des objectifs, l’ordre des priorités, puis la cible de la section des propositions', () => {
assert.deepEqual(
REGLAGES_GENERATION.map(({ nom, genre, section }) => [section, genre, nom]),
[
@ -78,7 +107,11 @@ describe('REGLAGES_GENERATION : la définition que lisent les écrans et la docu
['generation', 'bascule', 'nouveauxVoisins'],
['generation', 'bascule', 'nouvelleTable'],
['generation', 'bascule', 'varierAppartenances'],
['generation', 'bascule', 'respecterObjectifs'],
['generation', 'bascule', 'rencontrerAnimateurs'],
['generation', 'bascule', 'attribuerSieges'],
['generation', 'choix', 'modeObjectifs'],
['generation', 'ordre', 'priorites'],
['propositions', 'champ', 'cibleRedondance'],
],
);
@ -94,13 +127,55 @@ describe('REGLAGES_GENERATION : la définition que lisent les écrans et la docu
}
assert.deepEqual(champs.map(({ unite }) => unite), ['propositions', 'mouvements', 'mouvements']);
const bascules = reglagesDuGenre(generation, 'bascule');
assert.equal(bascules.length, 5);
assert.equal(bascules.length, 7);
for (const { nom, cle, etiquette, unite } of bascules) {
assert.equal(cle, nom);
assert.equal(etiquette, `generation.contrainte.${nom}`);
assert.equal(unite, null);
}
assert.equal(champs.length + bascules.length, generation.length);
const choix = reglagesDuGenre(generation, 'choix');
const ordres = reglagesDuGenre(generation, 'ordre');
assert.equal(champs.length + bascules.length + choix.length + ordres.length, generation.length);
});
test('le mode des objectifs : un choix à la racine des réglages, ses trois valeurs celles du registre, le plus de places servies par défaut, sous la case des objectifs, réglant les tours hors objectif', () => {
const choix = reglagesDuGenre(REGLAGES_GENERATION, 'choix');
assert.deepEqual(choix, [
{
nom: 'modeObjectifs',
cle: 'modeObjectifs',
etiquette: 'generation.reglage.modeObjectifs',
unite: null,
defaut: 'places',
genre: 'choix',
section: 'generation',
valeurs: [...MODES_OBJECTIFS],
sous: 'respecterObjectifs',
critere: 'toursHorsObjectif',
},
]);
assert.ok(Object.isFrozen(choix[0].valeurs));
assert.equal(choix[0].defaut, modeObjectifs({ reglages: {} }));
assert.ok(reglagesDuGenre(REGLAGES_GENERATION, 'bascule').some(({ cle }) => cle === choix[0].sous));
for (const valeur of choix[0].valeurs) {
assert.notEqual(t(`${choix[0].etiquette}.${valeur}`), `${choix[0].etiquette}.${valeur}`, valeur);
assert.notEqual(t(`${choix[0].etiquette}.${valeur}.explication`), `${choix[0].etiquette}.${valeur}.explication`, valeur);
}
});
test('l’ordre des priorités : une entrée à la racine des réglages, sans unité, l’ordre par défaut que lit le stockage', () => {
const [ordre] = reglagesDuGenre(REGLAGES_GENERATION, 'ordre');
assert.deepEqual({ ...ordre, defaut: null }, {
nom: 'priorites',
cle: 'priorites',
etiquette: 'generation.reglage.priorites',
unite: null,
defaut: null,
genre: 'ordre',
section: 'generation',
});
assert.deepEqual(ordre.defaut, priorites({ reglages: {} }));
assert.ok(Object.isFrozen(ordre.defaut));
});
test('la cible de la somme de redondance : un champ de la section des propositions, à la racine des réglages, sous le libellé de son champ, sans unité', () => {
@ -130,8 +205,15 @@ describe('REGLAGES_GENERATION : la définition que lisent les écrans et la docu
);
assert.deepEqual(
reglagesDuGenre(generation, 'bascule').map(({ defaut }) => defaut),
[true, true, true, true, false],
[true, true, true, true, true, false, false],
);
// Les cases facultatives, absentes d'une charge neuve : leur défaut est
// celui que le stockage leur lit.
for (const cle of ['respecterObjectifs', 'rencontrerAnimateurs']) {
assert.equal(Object.hasOwn(reglages, cle), false, cle);
assert.equal(REGLAGES_GENERATION.find((entree) => entree.cle === cle).defaut, critereCoche({ reglages: {} }, cle), cle);
}
assert.equal(Object.hasOwn(reglages, 'modeObjectifs') || Object.hasOwn(reglages, 'priorites'), false);
});
test('chaque réglage du schéma du fichier, facultatif compris, a son entrée, et chaque entrée en désigne un : aucun n’échappe aux écrans ni au tableau, hors des réglages nommés sans entrée', () => {
@ -148,10 +230,10 @@ describe('REGLAGES_GENERATION : la définition que lisent les écrans et la docu
test('chaque entrée s’écrit par la commande de sa section, et chaque libellé existe', () => {
let charge = chargeNeuveEpreuve();
for (const entree of REGLAGES_GENERATION) {
const valeur = entree.genre === 'bascule' ? !entree.defaut : entree.defaut + 1;
const valeur = autreValeur(entree);
const [commande, args] = ECRITURES.get(entree.section)(entree, valeur);
charge = appliquerCommande(commande, charge, args).charge;
assert.equal(valeurDuReglage(charge.reglages, entree), valeur, entree.cle);
assert.deepEqual(valeurDuReglage(charge.reglages, entree), valeur, entree.cle);
assert.notEqual(t(entree.etiquette), entree.etiquette);
}
assert.deepEqual([...new Set(REGLAGES_GENERATION.map(({ section }) => section))], [...ECRITURES.keys()]);
@ -173,6 +255,18 @@ describe('valeurDuReglage, reglagesDuGenre et reglagesDeSection', () => {
const cible = REGLAGES_GENERATION.find(({ nom }) => nom === 'cibleRedondance');
assert.equal(valeurDuReglage({ generation: {} }, cible), CIBLE_REDONDANCE_PAR_DEFAUT);
assert.equal(valeurDuReglage({ generation: {} }, { ...cible, defaut: 3 }), 3);
for (const entree of REGLAGES_GENERATION.filter(({ genre }) => genre !== 'champ')) {
assert.equal(valeurDuReglage({ generation: {} }, entree), entree.defaut, entree.cle);
}
});
test('le mode et l’ordre se lisent à la racine des réglages, comme les cases facultatives', () => {
const lire = (nom, reglages) => valeurDuReglage(reglages, REGLAGES_GENERATION.find((entree) => entree.nom === nom));
const ordre = [[{ critere: 'redondance', poids: 1 }]];
assert.equal(lire('modeObjectifs', { modeObjectifs: 'minimum' }), 'minimum');
assert.equal(lire('priorites', { priorites: ordre }), ordre);
assert.equal(lire('respecterObjectifs', { respecterObjectifs: false }), false);
assert.equal(lire('rencontrerAnimateurs', { rencontrerAnimateurs: true }), true);
});
test('un genre inconnu lève, et le nomme', () => {
@ -187,7 +281,7 @@ describe('valeurDuReglage, reglagesDuGenre et reglagesDeSection', () => {
);
assert.deepEqual(
reglagesDeSection(definition, 'generation').slice(0, 2).map(({ nom }) => nom),
['attribuerSieges', 'varierAppartenances'],
['priorites', 'modeObjectifs'],
);
assert.deepEqual(reglagesDuGenre([], 'bascule'), []);
assert.deepEqual(reglagesDeSection([], 'generation'), []);

View file

@ -2,14 +2,17 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Le contenu d'un aperçu d'import (§ 10.1, § 10.2), celui que rend
// modeleApercu (modele.js) : la source — le nom du fichier, ou le texte
// collé —, l'encodage et le séparateur nommés, le refus global avec sa
// cause et son remède, l'association des colonnes et ses ambiguïtés, les
// lignes valides, les lignes refusées et leur motif, les orthographes
// fondues, les doublons, les homonymes d'une mise à jour, les cellules sur
// plusieurs lignes. Le rapport le reprend pour l'aperçu appliqué, ses
// refus remplacés par ceux du résumé.
// Le contenu de l'aperçu d'un import, § 10.1, § 10.2 et § 10.4, celui que
// rend modeleApercu (modele.js), de participants ou d'objectifs de table : la
// source — le nom du fichier, ou le texte collé —, l'encodage et le
// séparateur nommés, le refus global avec sa cause et son remède — texte,
// ou commande du geste qui y mène —, l'association des colonnes et ses
// ambiguïtés, les lignes valides sous les colonnes de leur nature, les
// lignes refusées et leur motif, les orthographes fondues d'appartenances
// puis, dans un bloc à part, celles d'étiquettes, les doublons, les
// homonymes d'une mise à jour, les cellules sur plusieurs lignes. Le
// rapport le reprend pour l'aperçu appliqué, ses refus remplacés par ceux
// du résumé. Une cellule d'étiquettes montre chacune en pastille.
//
// modele : le modèle ; source : { origine: 'fichier'|'collage', nom } ;
// participants : ceux de la charge, qui nomment les personnes désignées ;
@ -20,23 +23,29 @@
//
// Chaque message est classé (Message, messageDuBloc) : le refus global et
// les lignes refusées en refus, les doublons, les homonymes et les
// cellules sur plusieurs lignes en avis, les orthographes fondues en
// information. Les tableaux prennent le langage des cartes : en-tête sur
// cellules sur plusieurs lignes en avis, les orthographes fondues,
// d'appartenances et d'étiquettes, en information. Les tableaux prennent le langage des cartes : en-tête sur
// le fond sourd, lignes séparées d'un filet léger.
import { getContext } from 'svelte';
import { actionRemede } from '../primitives/commande.js';
import Commande from '../primitives/Commande.svelte';
import Pastille from '../primitives/Pastille.svelte';
import Message from './Message.svelte';
import { COLONNES_DES_LIGNES, messageDuBloc, motifLigne, nommer } from './modele.js';
import { messageDuBloc, motifLigne, nommer } from './modele.js';
let { modele, source, participants = [], refusees = null, choisir = null } = $props();
const t = getContext('t');
const format = getContext('format');
const seance = getContext('seance');
const ui = getContext('ui') ?? null;
const motif = motifLigne(t);
const refuseesMontrees = $derived(refusees ?? modele.refusees);
const global = $derived(modele.global);
const refusGlobal = messageDuBloc('global');
// La commande du geste qui remédie au refus global, null sans geste.
const remedeGlobal = $derived(global?.remede.geste ? actionRemede(global.remede.geste, { seance, ui }) : null);
</script>
<div class="apercu">
@ -65,6 +74,9 @@
{#if !global.remede.dansLaCause}
<p>{t(global.remede.cle, global.remede.details)}</p>
{/if}
{#if remedeGlobal !== null}
<Commande cle={remedeGlobal.cle} details={remedeGlobal.details} nom={remedeGlobal.nom} executer={remedeGlobal.executer} />
{/if}
</div>
{/if}
@ -115,7 +127,7 @@
<thead>
<tr>
<th scope="col">{t('import.apercu.ligne')}</th>
{#each COLONNES_DES_LIGNES as { champ, cle } (champ)}
{#each modele.champs as { champ, cle } (champ)}
<th scope="col">{t(cle)}</th>
{/each}
</tr>
@ -124,8 +136,18 @@
{#each modele.lignes as { ligne, cellules } (ligne)}
<tr data-ligne={ligne}>
<th scope="row">{format.nombre(ligne)}</th>
{#each cellules as { champ, texte, cle } (champ)}
<td data-cellule={champ}>{cle === null ? (texte ?? '') : t(cle)}</td>
{#each cellules as { champ, texte, cle, etiquettes } (champ)}
<td data-cellule={champ}>
{#if etiquettes !== null}
{#if etiquettes.length > 0}
<ul class="etiquettes">
{#each etiquettes as motCle (motCle)}
<li><Pastille sorte="statut" valeur="neutre" icone="etiquettes" libelle={motCle} /></li>
{/each}
</ul>
{/if}
{:else}{cle === null ? (texte ?? '') : t(cle)}{/if}
</td>
{/each}
</tr>
{/each}
@ -189,6 +211,27 @@
</Message>
{/if}
{#if modele.fusionsEtiquettes.length > 0}
<Message bloc="fusionsEtiquettes" titre={t('import.apercu.fusionsEtiquettes')}>
<ul data-fusions-etiquettes>
{#each modele.fusionsEtiquettes as fusion (fusion.retenue)}
<li>
<span>{t('import.apercu.retenue')}</span>
<strong data-affichee>{fusion.retenue}</strong>
<span>{t('import.apercu.orthographes')}</span>
<ul class="orthographes">
{#each fusion.orthographes as orthographe (orthographe)}
<li data-orthographe><span data-texte>{orthographe}</span></li>
{/each}
</ul>
<span data-nombre>{format.nombre(fusion.lignes)}</span>
<span>{t('import.apercu.lignesReecrites', { n: fusion.lignes })}</span>
</li>
{/each}
</ul>
</Message>
{/if}
{#if modele.doublons.length > 0}
<Message bloc="doublons" titre={t('import.apercu.doublons')}>
<ul data-doublons>
@ -324,6 +367,13 @@
text-align: right;
}
/* Un motif cite la valeur fautive, qu'aucun blanc ne coupe parfois — une
étiquette trop longue — : il passe à la ligne n'importe où plutôt que
d'élargir la page. */
.refusees td {
overflow-wrap: anywhere;
}
.defile {
max-height: 24rem;
overflow: auto;
@ -351,6 +401,26 @@
padding-left: var(--espace-5);
}
/* Les orthographes d'une étiquette fondue, en ligne, séparées d'un blanc. */
.orthographes {
display: inline-flex;
flex-wrap: wrap;
gap: var(--espace-2);
padding: 0;
list-style: none;
}
/* Les étiquettes d'une cellule, une Pastille chacune, celle de la fiche :
neutre, à l'icône des étiquettes. La cellule passe à la ligne entre deux
pastilles. */
.etiquettes {
display: flex;
flex-wrap: wrap;
gap: var(--espace-1);
padding: 0;
list-style: none;
}
.personne {
display: inline-flex;
gap: var(--espace-1);

View file

@ -1,11 +1,13 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// L'import des participants dans un vrai moteur de rendu (§ 10.1, § 10.2,
// § 10.3, § 2.1 étape 2, § 9) : l'aperçu obligatoire, l'encodage et le
// L'import des participants et des objectifs de table dans un vrai moteur de
// rendu (§ 10.1, § 10.2, § 10.3, § 10.4, § 2.1 étape 2, § 9) : la nature
// choisie, jamais devinée, l'aperçu obligatoire, l'encodage et le
// séparateur nommés, l'association par en-tête, l'ambiguïté, les refus
// d'une ligne et les refus globaux, le rapport, le CSV des refus, les trois
// modes, l'import selon l'état, le collage. Chaque épreuve monte App par
// d'une ligne et les refus globaux, les étiquettes en pastilles et leurs
// fusions à part, le rapport, le CSV des refus, les modes, l'import selon
// l'état, le collage. Chaque épreuve monte App par
// monterApp, sur le système de fichiers simulé, ouvre un événement et sa
// section d'import ; le dialogue de choix sert, dans l'ordre, les fichiers
// que l'épreuve prépare, et le dialogue d'enregistrement relève ce qu'il
@ -29,17 +31,19 @@ import { apercevoir } from '../../csv/apercu.js';
import { decoder } from '../../csv/encodage.js';
import { bilanRemplacement, exporterRefus as exporterRefusCsv } from '../../csv/import.js';
import { decouper } from '../../csv/lecture.js';
import { apercevoirObjectifs, bilanRemplacementObjectifs } from '../../csv/objectifs.js';
import { serialiser } from '../../stockage/canonique.js';
import { creerCharge } from '../../stockage/document.js';
import { lireJournal } from '../../stockage/journal.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js';
import { pointer } from '../../../test/navigateur/entrees.js';
import { pointer, touche } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import { composantes, visible } from '../../../test/navigateur/rendu.js';
import urlCasLimites from '../../../exemples/participants_cas_limites.csv?url';
import urlCasLimites1252 from '../../../exemples/participants_cas_limites_cp1252.csv?url';
import urlGrande from '../../../exemples/participants_demo_grande.csv?url';
import urlObjectifs from '../../../exemples/objectifs_cas_limites.csv?url';
import { TABLES_INTERFACE } from '../libelles.js';
import { ENCODAGES, SEPARATEURS } from './modele.js';
@ -93,10 +97,12 @@ afterEach(() => {
* dialogue de choix sert aServir dans l'ordre ; l'enregistreur relève
* enregistres et rend le nom proposé.
*/
async function ouvrir({ participants = [], ecriture = true, langue = 'fr', siegesParDefaut = 6, prepare = null } = {}) {
async function ouvrir({ participants = [], tables = [], ecriture = true, langue = 'fr', siegesParDefaut = 6, prepare = null } = {}) {
const charge = creerCharge({ id: 'epreuve-import', nom: 'Soirée des Merles', siegesParDefaut, tours: 2 });
charge.participants = participants;
charge.prochainsIds.participant = participants.length + 1;
charge.tables = tables;
charge.prochainsIds.table = tables.length + 1;
const texte = serialiser(charge, { revision: 1, produitVersion: VERSION.affichee });
const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `${BASE}.gtt.json`, texte }], langue });
montes.push(m);
@ -123,7 +129,7 @@ const tous = (e, selecteur) => [...section(e).querySelectorAll(selecteur)];
const commande = (e, nom) => dans(e, `[data-commande="${nom}"]`);
const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
const ecritures = ({ m }) => m.trace.length;
const imports = ({ espion }) => espion.mock.calls.filter(([nom]) => nom === 'importerParticipants');
const imports = ({ espion }, nom = 'importerParticipants') => espion.mock.calls.filter(([appel]) => appel === nom);
// Sert un fichier au dialogue et le choisit : l'aperçu s'ouvre sur lui.
async function choisir(e, nom, octets) {
@ -146,13 +152,13 @@ function choisirMode(e, mode) {
flushSync();
}
// Clique « Importer » et attend la fin de l'appel à la séance qu'il fait :
// rend ce que rend seance.executer, ou le refus.
async function importer(e) {
const avant = imports(e).length;
// Clique « Importer » et attend la fin de l'appel à la séance qu'il fait,
// la commande nom : rend ce que rend seance.executer, ou le refus.
async function importer(e, nom = 'importerParticipants') {
const avant = imports(e, nom).length;
commande(e, 'importer').click();
await vi.waitFor(() => expect(imports(e).length).toBe(avant + 1));
const resultat = e.espion.mock.results.filter((_, rang) => e.espion.mock.calls[rang][0] === 'importerParticipants').at(-1);
await vi.waitFor(() => expect(imports(e, nom).length).toBe(avant + 1));
const resultat = e.espion.mock.results.filter((_, rang) => e.espion.mock.calls[rang][0] === nom).at(-1);
try {
const rendu = await resultat.value;
await vi.waitFor(() => expect(dans(e, '[data-rapport]')).not.toBeNull());
@ -164,12 +170,18 @@ async function importer(e) {
}
// Les lignes valides montrées sous un conteneur : le texte de chaque
// cellule, dans l'ordre.
// cellule, dans l'ordre. Une cellule d'étiquettes se lit pastille par
// pastille, chacune par son libellé, sans l'espace que la Pastille pose
// entre son icône et lui.
const texteCellule = (cellule) => {
const pastilles = [...cellule.querySelectorAll('li')];
return pastilles.length === 0 ? cellule.textContent : pastilles.map(texteDe).join('');
};
const lignesMontrees = (conteneur) =>
[...conteneur.querySelectorAll('[data-lignes] [data-ligne]')].map((ligne) =>
[...ligne.querySelectorAll('[data-cellule]')].map((cellule) => cellule.textContent));
[...ligne.querySelectorAll('[data-cellule]')].map(texteCellule));
const cellule = (conteneur, ligne, champ) =>
conteneur.querySelector(`[data-lignes] [data-ligne="${ligne}"] [data-cellule="${champ}"]`).textContent;
texteCellule(conteneur.querySelector(`[data-lignes] [data-ligne="${ligne}"] [data-cellule="${champ}"]`));
const refusee = (conteneur, ligne) => conteneur.querySelector(`[data-refusees] [data-ligne="${ligne}"]`);
const association = (conteneur) =>
[...conteneur.querySelectorAll('[data-colonne]')].map((colonne) => [
@ -213,6 +225,17 @@ function vu(element) {
return visible(element);
}
// La couleur qu'un jeton donne dans le thème courant, relue sur une sonde.
function jeton(nom) {
const sonde = document.createElement('span');
sonde.style.color = `var(${nom})`;
document.body.append(sonde);
const couleur = composantes(getComputedStyle(sonde).color);
sonde.remove();
return couleur;
}
const fondDe = (element) => composantes(getComputedStyle(element).backgroundColor);
describe('l’aperçu est obligatoire (§ 10.1)', () => {
test('choisir les cas limites ouvre l’aperçu, sans rien écrire ; déposer un fichier sur la fenêtre ouvre son aperçu ; aucun contrôle hors de l’import n’importe', async () => {
const e = await ouvrir();
@ -911,16 +934,6 @@ describe('les cas du § 10.3', () => {
});
describe('l’habillage : cartes et messages classés en refus, avis ou information', () => {
// La couleur qu'un jeton donne dans le thème courant, relue sur une sonde.
function jeton(nom) {
const sonde = document.createElement('span');
sonde.style.color = `var(${nom})`;
document.body.append(sonde);
const couleur = composantes(getComputedStyle(sonde).color);
sonde.remove();
return couleur;
}
const fondDe = (element) => composantes(getComputedStyle(element).backgroundColor);
const nomDe = (region) => texteDe(document.getElementById(region.getAttribute('aria-labelledby')));
// La classe d'un message, lue dans son bloc : la pastille dont le texte
@ -941,9 +954,11 @@ describe('l’habillage : cartes et messages classés en refus, avis ou informat
exigerSorte(bloc('[data-refusees]'), 'refus', `${lieu}, lignes refusées`);
exigerSorte(bloc('[data-doublons]'), 'avis', `${lieu}, doublons`);
exigerSorte(bloc('[data-fusions]'), 'information', `${lieu}, orthographes fondues`);
exigerSorte(bloc('[data-fusions-etiquettes]'), 'information', `${lieu}, étiquettes fondues`);
expect(bloc('[data-fusions-etiquettes]'), lieu).not.toBe(bloc('[data-fusions]'));
}
test('les cas limites : refusées en refus, doublons en avis, fusions en information, dans l’aperçu puis le rapport', async () => {
test('les cas limites : refusées en refus, doublons en avis, fusions d’appartenances et d’étiquettes en information, chacune son bloc, dans l’aperçu puis le rapport', async () => {
const e = await ouvrir();
await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites));
exigerBlocs(dans(e, '[data-apercu]'), 'aperçu');
@ -976,6 +991,21 @@ describe('l’habillage : cartes et messages classés en refus, avis ou informat
exigerSorte(rien, 'information', 'rapport, rien d’écrit');
});
// Un import d'objectifs n'écrit rien quand chaque couple du fichier porte
// déjà son objectif et qu'il refuse des lignes : ici, la table 1 porte
// déjà « finance », et aucune table n'affiche le numéro 9.
test('un import d’objectifs qui n’a rien écrit le dit en information, avec le texte des objectifs', async () => {
const e = await ouvrir({ tables: [table(1, 1, [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }]), table(2, 2)] });
choisirNature(e, 'objectifs');
await choisir(e, 'inchange.csv', enUtf8('table;objectif\r\n1;finance\r\n9;finance\r\n'));
const { rendu } = await importer(e, 'importerObjectifs');
expect(rendu.libelle).toBeNull();
const rapport = dans(e, '[data-rapport]');
const rien = [...rapport.querySelectorAll('section')].find((bloc) => texteDe(bloc).includes(t('import.rapport.rienEcritObjectifs'))) ?? null;
exigerSorte(rien, 'information', 'rapport des objectifs, rien d’écrit');
expect(texteDe(rapport)).not.toContain(t('import.rapport.rienEcrit'));
});
test('un refus global porte la pastille du refus, sa cause et son remède', async () => {
const e = await ouvrir();
await coller(e, 'Ombrelle\tIris\nPervenche\tThéo');
@ -997,3 +1027,363 @@ describe('l’habillage : cartes et messages classés en refus, avis ou informat
expect(regions()).not.toContain(t('import.apercu.titre'));
});
});
// --- La nature du fichier, les objectifs de table, les étiquettes (§ 10.1, § 10.4) ---
// Une table de la charge d'épreuve, ses objectifs donnés.
const table = (id, numero, objectifs = null) => ({
id, numero, sieges: null, forme: 'ronde', position: { x: 0, y: 0 }, ...(objectifs === null ? {} : { objectifs }),
});
// Neuf tables numérotées 1, 2, 3, 3, puis 4 à 8 : deux portent le numéro 3.
const NEUF_TABLES = () => [1, 2, 3, 3, 4, 5, 6, 7, 8].map((numero, rang) => table(rang + 1, numero));
// Le CSV des objectifs qui refuse une ligne pour chaque motif du § 10.4,
// dans l'ordre du texte ; huit lignes valides précèdent le champ en trop,
// que l'analyse du séparateur ne lit qu'au-delà de ses vingt premiers
// enregistrements.
const VINGT_ET_UNE = Array.from({ length: 21 }, (_, rang) => `mot${rang + 1}`).join('|');
const TEXTE_DES_MOTIFS = [
'table;tour;objectif;regle',
'1;;finance;',
';;finance;',
'un;;finance;',
'9;;finance;',
'3;;finance;',
'2;2.0;finance;',
'2;5;finance;',
'2;1;;',
`2;1;${'x'.repeat(41)};`,
`2;1;${VINGT_ET_UNE};`,
'2;1;finance;peut-être',
'1;;Finance;et',
'2;2;FINANCE;toutes',
'4;;bénévole;ou',
'4;1;bénévole;',
'4;2;bénévole;',
'5;;bénévole;',
'5;1;bénévole;',
'5;2;bénévole;',
'6;;bénévole;',
'6;1;bénévole;une',
'1;1;finance;;en trop',
'',
].join('\r\n');
const MOTIFS_DU_10_4 = [
'TABLE_ABSENTE', 'TABLE_NON_ENTIERE', 'NUMERO_TABLE_INCONNU', 'PLUSIEURS_TABLES', 'TOUR_NON_ENTIER', 'TOUR_HORS_SOIREE',
'OBJECTIF_VIDE', 'ETIQUETTE_MAL_FORMEE', 'ETIQUETTES_TROP_NOMBREUSES', 'REGLE_INCONNUE', 'OBJECTIF_EN_DOUBLE', 'CHAMPS_EN_TROP',
];
// Coche une nature du fichier.
function choisirNature(e, nature) {
dans(e, `[data-nature-import] input[value="${nature}"]`).click();
flushSync();
}
const natureCochee = (e) => dans(e, '[data-nature-import] input:checked')?.value ?? null;
// Les étiquettes montrées en pastilles dans une cellule de l'aperçu.
const pastilles = (conteneur, ligne, champ) =>
[...conteneur.querySelectorAll(`[data-lignes] [data-ligne="${ligne}"] [data-cellule="${champ}"] li`)].map(texteDe);
// Un premier geste, sans effet sur les objectifs ni sur les tables : le
// journal existe avant l'import, dont l'entrée se compte seule.
const premierGeste = (seance) => seance.executer('changerRegleObjectifs', { regle: 'et' });
// Les comptes du rapport d'un import d'objectifs, affichés puis attendus.
const COMPTES_DES_OBJECTIFS = [
['ajoutes', 'import.rapport.objectifsAjoutes'],
['remplaces', 'import.rapport.objectifsRemplaces'],
['inchanges', 'import.rapport.objectifsInchanges'],
['retires', 'import.rapport.objectifsRetires'],
['tables', 'import.rapport.tablesTouchees'],
];
const comptesObjectifsAffiches = (e) =>
COMPTES_DES_OBJECTIFS.map(([nom]) => {
const compte = dans(e, `[data-rapport] [data-compte="${nom}"]`);
return [nom, compte.querySelector('[data-nombre]').textContent, compte.querySelector('[data-nombre] + span').textContent];
});
const comptesObjectifsDu = (resume) =>
COMPTES_DES_OBJECTIFS.map(([nom, cle]) => [nom, format.nombre(resume[nom]), t(cle, { n: resume[nom] })]);
describe('la nature du fichier : choisie avant l’aperçu, jamais devinée (§ 10.4)', () => {
test('Participants est cochée par défaut, avant tout fichier ; le CSV des objectifs livré, lu sous Participants, est refusé sans commande d’import ; coché Objectifs, le même texte se lit en objectifs', async () => {
const e = await ouvrir({ tables: NEUF_TABLES() });
expect(dans(e, '[data-apercu]')).toBeNull();
const choix = dans(e, '[data-nature-import]');
expect(vu(choix)).toBe(true);
expect(natureCochee(e)).toBe('participants');
expect([...choix.querySelectorAll('label')].map(texteDe)).toEqual([t('import.nature.participants'), t('import.nature.objectifs')]);
expect(texteDe(choix.querySelector('legend'))).toBe(t('import.nature.titre'));
const octets = await lireLivre(urlObjectifs);
await choisir(e, 'objectifs_cas_limites.csv', octets);
expect(natureCochee(e)).toBe('participants');
expect(dans(e, '[data-apercu] [data-refus="AUCUN_ENTETE"]')).not.toBeNull();
expect(commande(e, 'importer')).toBeNull();
expect(tous(e, '[data-apercu] [data-champ="mode"]')).toEqual([]);
choisirNature(e, 'objectifs');
expect(natureCochee(e)).toBe('objectifs');
expect(dans(e, '[data-apercu] [data-fichier]').textContent).toBe('objectifs_cas_limites.csv');
expect(dans(e, '[data-apercu] [data-refus]')).toBeNull();
const apercu = apercevoirObjectifs(decoder(octets).texte, e.m.seance.etat().charge);
expect(association(dans(e, '[data-apercu]'))).toEqual([
['Table', t('import.champ.table')],
['Tour', t('import.champ.tour')],
['Objectif', t('import.champ.objectif')],
['Règle', t('import.champ.regle')],
]);
expect(tous(e, '[data-apercu] [data-lignes] [data-ligne]').map((ligne) => Number(ligne.dataset.ligne))).toEqual(
apercu.lignes.map(({ ligne }) => ligne),
);
expect(tous(e, '[data-apercu] [data-refusees] [data-ligne]').map((ligne) => Number(ligne.dataset.ligne))).toEqual(
apercu.refusees.map(({ ligne }) => ligne),
);
expect([...dans(e, '[data-champ="mode"]').querySelectorAll('input')].map((input) => input.value)).toEqual(['mettreAJour', 'remplacer']);
expect(dans(e, '[data-champ="mode"] input:checked').value).toBe('mettreAJour');
expect(commande(e, 'importer')).not.toBeNull();
});
test('une liste de participants qui porte une colonne « Table », lue sous Participants : la table ignorée, des personnes ; sous Objectifs, refusée faute d’objectif', async () => {
const e = await ouvrir({ tables: NEUF_TABLES() });
await choisir(e, 'invites.csv', enUtf8('nom;prenom;table\r\nOmbrelle;Iris;2\r\nPervenche;Théo;1\r\n'));
expect(association(dans(e, '[data-apercu]')).at(-1)).toEqual(['table', t('import.colonne.nonReconnue')]);
expect(lignesMontrees(dans(e, '[data-apercu]')).map(([nom]) => nom)).toEqual(['Ombrelle', 'Pervenche']);
choisirNature(e, 'objectifs');
expect(dans(e, '[data-apercu] [data-refus="OBJECTIF_NON_ASSOCIE"]')).not.toBeNull();
expect(texteDe(dans(e, '[data-apercu] [data-refus]'))).toContain(t('import.remede.objectifNonAssocie'));
expect(commande(e, 'importer')).toBeNull();
choisirNature(e, 'participants');
expect(dans(e, '[data-apercu] [data-refus]')).toBeNull();
expect(dans(e, '[data-champ="mode"] input:checked').value).toBe('ajouter');
});
test('au clavier : la flèche passe de Participants à Objectifs de table, l’aperçu ouvert se relit, et le bouton qui a le focus porte l’anneau de l’application', async () => {
const e = await ouvrir({ tables: NEUF_TABLES() });
await choisir(e, 'objectifs_cas_limites.csv', await lireLivre(urlObjectifs));
dans(e, '[data-nature-import] input[value="participants"]').focus();
await touche('ArrowDown');
await vi.waitFor(() => expect(natureCochee(e)).toBe('objectifs'));
const actif = document.activeElement;
expect(actif.value).toBe('objectifs');
expect(actif.matches(':focus-visible')).toBe(true);
const style = getComputedStyle(actif);
expect(style.outlineStyle).toBe('solid');
const sonde = document.createElement('span');
sonde.style.color = 'var(--couleur-focus)';
document.body.append(sonde);
expect(style.outlineColor).toBe(getComputedStyle(sonde).color);
sonde.remove();
await vi.waitFor(() => expect(dans(e, '[data-apercu] [data-refus]')).toBeNull());
expect(commande(e, 'importer')).not.toBeNull();
});
test('un collage de la grille coche Participants, quelle que soit la nature cochée', async () => {
const e = await ouvrir({ tables: NEUF_TABLES() });
choisirNature(e, 'objectifs');
await coller(e, 'nom\tprenom\nOmbrelle\tIris\n');
expect(natureCochee(e)).toBe('participants');
expect(lignesMontrees(dans(e, '[data-apercu]')).map(([nom]) => nom)).toEqual(['Ombrelle']);
});
});
describe('l’aperçu des objectifs de table (§ 10.4)', () => {
test('un refus de chaque motif du § 10.4 rendu dans l’aperçu, à son rang, son motif celui de t ; les lignes valides montrent numéro, tour, étiquettes en pastilles et règle', async () => {
const e = await ouvrir({ tables: NEUF_TABLES() });
choisirNature(e, 'objectifs');
await choisir(e, 'motifs.csv', enUtf8(TEXTE_DES_MOTIFS));
const apercu = apercevoirObjectifs(TEXTE_DES_MOTIFS, e.m.seance.etat().charge);
expect(apercu.refusees.map(({ code }) => code)).toEqual(MOTIFS_DU_10_4);
const ici = dans(e, '[data-apercu]');
for (const { code, brut, ...details } of apercu.refusees) {
const ligne = refusee(ici, details.ligne);
expect(ligne, code).not.toBeNull();
expect(vu(ligne), code).toBe(true);
expect(ligne.querySelector('[data-motif]').textContent, code).toBe(t(code, details));
}
const [{ retenue }] = apercu.fusionsEtiquettes;
expect(lignesMontrees(ici)[0]).toEqual(['1', t('import.tour.soiree'), retenue, t('import.regle.defaut')]);
expect(cellule(ici, 14, 'tour')).toBe('2');
expect(pastilles(ici, 14, 'objectif')).toEqual([retenue]);
expect(cellule(ici, 14, 'regle')).toBe(t('import.regle.et'));
expect(cellule(ici, 15, 'regle')).toBe(t('import.regle.ou'));
expect(texteDe(dans(e, '[data-apercu] [data-valides]'))).toContain(format.nombre(apercu.lignes.length));
});
test('« finance » et « FINANCE » fondus en une orthographe : le bloc des étiquettes fondues, en information, nomme la retenue, les orthographes et les lignes réécrites ; aucun bloc d’appartenances', async () => {
const e = await ouvrir({ tables: NEUF_TABLES() });
choisirNature(e, 'objectifs');
await choisir(e, 'motifs.csv', enUtf8(TEXTE_DES_MOTIFS));
const [fusion] = apercevoirObjectifs(TEXTE_DES_MOTIFS, e.m.seance.etat().charge).fusionsEtiquettes;
const bloc = dans(e, '[data-apercu] [data-fusions-etiquettes]');
expect(vu(bloc)).toBe(true);
expect(bloc.querySelector('[data-affichee]').textContent).toBe(fusion.retenue);
expect([...bloc.querySelectorAll('[data-orthographe] [data-texte]')].map((o) => o.textContent)).toEqual(fusion.orthographes);
expect(bloc.querySelector(':scope > li > [data-nombre]').textContent).toBe(format.nombre(fusion.lignes));
expect(texteDe(bloc.closest('section'))).toContain(t('import.apercu.fusionsEtiquettes'));
expect(texteDe(bloc.closest('section'))).toContain(t('import.sorte.information'));
expect(dans(e, '[data-apercu] [data-fusions]')).toBeNull();
});
test('un événement sans table : OBJECTIFS_SANS_TABLE, et son remède ouvre la section des tables', async () => {
const e = await ouvrir();
choisirNature(e, 'objectifs');
await choisir(e, 'objectifs_cas_limites.csv', await lireLivre(urlObjectifs));
const pose = dans(e, '[data-apercu] [data-refus="OBJECTIFS_SANS_TABLE"]');
expect(vu(pose)).toBe(true);
expect(texteDe(pose)).toContain(t('OBJECTIFS_SANS_TABLE'));
const remede = pose.querySelector('[data-commande="ouvrirSection"]');
expect(remede.textContent.trim()).toBe(t('communs.remede.ouvrirSection.tables'));
expect(commande(e, 'importer')).toBeNull();
remede.click();
await vi.waitFor(() => expect(e.m.ui.etat().section).toBe('tables'));
});
test('chaque refus global propre aux objectifs : sa cause et son remède, aucune commande d’import, rien d’écrit', async () => {
const e = await ouvrir({ tables: NEUF_TABLES() });
choisirNature(e, 'objectifs');
const avant = ecritures(e);
const cas = [
['AUCUN_ENTETE', 'a;b\r\n1;finance\r\n', 'import.remede.aucunEnteteObjectifs'],
['TABLE_NON_ASSOCIEE', 'tour;objectif\r\n1;finance\r\n', 'import.remede.tableNonAssociee'],
['OBJECTIF_NON_ASSOCIE', 'table;tour\r\n1;1\r\n', 'import.remede.objectifNonAssocie'],
['AUCUNE_LIGNE_VALIDE', 'table;objectif\r\n9;finance\r\n', 'import.remede.aucuneLigneValide'],
];
for (const [code, texte, remede] of cas) {
await choisir(e, `${code}.csv`, enUtf8(texte));
const pose = dans(e, `[data-apercu] [data-refus="${code}"]`);
expect(pose, code).not.toBeNull();
expect(texteDe(pose), code).toContain(t(remede));
expect(commande(e, 'importer'), code).toBeNull();
expect(natureCochee(e), code).toBe('objectifs');
}
expect(ecritures(e)).toBe(avant);
});
});
describe('importer des objectifs de table (§ 10.4)', () => {
test('mettre à jour : une entrée, importerObjectifs sous l’aperçu montré ; le rapport porte les comptes du résumé et le bloc des étiquettes fondues ; le CSV des refus est celui d’exporterRefus', async () => {
const e = await ouvrir({ tables: NEUF_TABLES(), prepare: premierGeste });
choisirNature(e, 'objectifs');
await choisir(e, 'motifs.csv', enUtf8(TEXTE_DES_MOTIFS));
const apercu = apercevoirObjectifs(TEXTE_DES_MOTIFS, e.m.seance.etat().charge);
commande(e, 'enregistrerRefus').click();
await vi.waitFor(() => expect(e.enregistres.length).toBe(1));
expect(e.enregistres[0].nomPropose).toBe(`${BASE}-refus.csv`);
expect([...e.enregistres[0].octets]).toEqual([...exporterRefusCsv(apercu, libelle)]);
const entreesAvant = entrees(e);
const { rendu } = await importer(e, 'importerObjectifs');
expect(imports(e)).toEqual([]);
const [[, argumentsRecus]] = imports(e, 'importerObjectifs');
expect(argumentsRecus.mode).toBe('mettreAJour');
expect(argumentsRecus.apercu).toEqual(apercu);
expect(entrees(e)).toBe(entreesAvant + 1);
expect(rendu.resume).toMatchObject({ ajoutes: 10, remplaces: 0, inchanges: 0, retires: 0 });
expect(comptesObjectifsAffiches(e)).toEqual(comptesObjectifsDu(rendu.resume));
const rapport = dans(e, '[data-rapport]');
expect(vu(rapport.querySelector('h3'))).toBe(true);
expect(rapport.querySelector('[data-fusions-etiquettes]')).not.toBeNull();
expect(rapport.querySelectorAll('[data-refusees] [data-ligne]').length).toBe(MOTIFS_DU_10_4.length);
const tables = e.m.seance.etat().charge.tables;
const [{ retenue }] = apercu.fusionsEtiquettes;
expect(tables.find(({ id }) => id === 1).objectifs).toEqual([{ portee: 'tous', tour: null, regle: null, etiquettes: [retenue] }]);
// L'avis de A20 : l'événement n'a aucun participant, donc aucun des dix
// objectifs écrits n'a de correspondant. La commande le rend, la séance
// le relaie, la liste des avertissements de l'événement le montre.
expect(rendu.avertissements.map(({ code }) => code)).toEqual(Array(10).fill('OBJECTIF_SANS_CORRESPONDANT'));
const montres = [...e.m.racine.querySelectorAll('[data-avertissement="OBJECTIF_SANS_CORRESPONDANT"]')];
expect(montres.map((avis) => texteDe(avis.querySelector('p')))).toEqual(rendu.avertissements.map(({ code, details }) => t(code, details)));
expect(vu(montres[0])).toBe(true);
});
test('remplacer annonce ses deux comptes, objectifs et tables ; Annuler n’écrit rien ; Confirmer, une entrée, et les objectifs que le fichier ne nomme pas partent', async () => {
const soiree = (etiquettes) => ({ portee: 'tous', tour: null, regle: null, etiquettes });
const tables = [
table(1, 1, [soiree(['bénévole']), { portee: 'tour', tour: 2, regle: 'et', etiquettes: ['Montréal', 'finance'] }]),
table(2, 2, [soiree(['ressources humaines'])]),
table(3, 3),
];
const e = await ouvrir({ tables, prepare: premierGeste });
choisirNature(e, 'objectifs');
await choisir(e, 'remplace.csv', enUtf8('table;objectif\r\n3;finance\r\n'));
choisirMode(e, 'remplacer');
const bilan = bilanRemplacementObjectifs(e.m.seance.etat().charge);
expect(bilan).toEqual({ objectifs: 3, tables: 2 });
const attendus = [t('import.remplacer.objectifs', { n: 3 }), t('import.remplacer.tables', { n: 2 })];
expect(attendus).toEqual(['3 objectifs de table seront supprimés', '2 tables perdront leurs objectifs']);
const avant = ecritures(e);
commande(e, 'importer').click();
await vi.waitFor(() => expect(dans(e, 'dialog')?.open).toBe(true));
expect(texteDe(dans(e, 'dialog'))).toContain(t('import.remplacer.titreObjectifs'));
expect([...dans(e, 'dialog').querySelectorAll('li')].map((li) => li.textContent)).toEqual(attendus);
dans(e, 'dialog [data-commande="renoncer"]').click();
await vi.waitFor(() => expect(dans(e, 'dialog')).toBeNull());
expect(imports(e, 'importerObjectifs')).toEqual([]);
expect(ecritures(e)).toBe(avant);
const entreesAvant = entrees(e);
commande(e, 'importer').click();
await vi.waitFor(() => expect(dans(e, 'dialog')?.open).toBe(true));
dans(e, 'dialog [data-commande="confirmer"]').click();
await vi.waitFor(() => expect(dans(e, '[data-rapport]')).not.toBeNull());
expect(imports(e, 'importerObjectifs').map(([, { mode }]) => mode)).toEqual(['remplacer']);
expect(entrees(e)).toBe(entreesAvant + 1);
const { resume } = await e.espion.mock.results.at(-1).value;
expect(resume).toMatchObject({ ajoutes: 1, remplaces: 0, inchanges: 0, retires: 3, tables: 3 });
expect(comptesObjectifsAffiches(e)).toEqual(comptesObjectifsDu(resume));
expect(e.m.seance.etat().charge.tables.map(({ objectifs = [] }) => objectifs.length)).toEqual([0, 0, 1]);
});
test('bloqué : l’aperçu des objectifs s’ouvre, l’import est refusé à côté de la commande, rien d’écrit', async () => {
const e = await ouvrir({ tables: NEUF_TABLES(), prepare: (seance) => seance.executer('changerEtat', { etat: 'bloque' }) });
choisirNature(e, 'objectifs');
const avant = ecritures(e);
await choisir(e, 'objectifs_cas_limites.csv', await lireLivre(urlObjectifs));
const { refus } = await importer(e, 'importerObjectifs');
expect(refus.code).toBe('LECTURE_SEULE');
expect(commande(e, 'importer').parentElement.contains(dans(e, '[data-refus="LECTURE_SEULE"]'))).toBe(true);
expect(ecritures(e)).toBe(avant);
});
});
describe('les étiquettes des participants dans l’aperçu (§ 10.1, A17)', () => {
test('les cas limites : chaque cellule d’étiquettes en pastilles, sous l’orthographe retenue ; le bloc des étiquettes fondues présent, à part des appartenances, dans l’aperçu puis le rapport', async () => {
const e = await ouvrir();
const octets = await lireLivre(urlCasLimites);
await choisir(e, 'participants_cas_limites.csv', octets);
const apercu = apercevoir(decoder(octets).texte);
expect(apercu.fusionsEtiquettes.length).toBeGreaterThanOrEqual(1);
const ici = dans(e, '[data-apercu]');
for (const { ligne, champs } of apercu.lignes) expect(pastilles(ici, ligne, 'etiquettes'), `ligne ${ligne}`).toEqual(champs.etiquettes ?? []);
expect(apercu.lignes.some(({ champs }) => (champs.etiquettes ?? []).length > 1)).toBe(true);
// Chaque étiquette, la pastille de la fiche : neutre, à icône, de
// l'encre de la page ; son fond se détache de celui de la cellule.
const elements = [...ici.querySelectorAll('[data-cellule="etiquettes"] li')];
expect(elements.length).toBeGreaterThan(1);
for (const li of elements) {
const pastille = [li, ...li.querySelectorAll('*')].find((element) => fondDe(element)[3] > 0);
expect(fondDe(pastille), texteDe(li)).toEqual(jeton('--couleur-neutre-fond'));
expect(li.querySelector('svg'), texteDe(li)).not.toBeNull();
expect(composantes(getComputedStyle(pastille).color), texteDe(li)).toEqual(jeton('--couleur-page-texte'));
}
expect(vu(elements[0])).toBe(true);
expect(fondDe(elements[0].closest('td'))).not.toEqual(jeton('--couleur-neutre-fond'));
for (const lieu of ['[data-apercu]', '[data-rapport]']) {
if (lieu === '[data-rapport]') await importer(e);
const conteneur = dans(e, lieu);
const bloc = conteneur.querySelector('[data-fusions-etiquettes]');
expect(bloc, lieu).not.toBeNull();
expect([...bloc.querySelectorAll('[data-affichee]')].map((a) => a.textContent), lieu).toEqual(
apercu.fusionsEtiquettes.map(({ retenue }) => retenue),
);
const appartenances = conteneur.querySelector('[data-fusions]');
expect(appartenances.contains(bloc), lieu).toBe(false);
expect(bloc.contains(appartenances), lieu).toBe(false);
const retenues = new Set(apercu.fusionsEtiquettes.map(({ retenue }) => retenue));
for (const affichee of appartenances.querySelectorAll('[data-affichee]')) expect(retenues.has(affichee.textContent), lieu).toBe(false);
expect(appartenances.querySelectorAll('[data-fusion]').length, lieu).toBe(apercu.fusions.length);
}
});
});

View file

@ -2,40 +2,40 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Le rapport d'un import appliqué (§ 10.1) : les comptes du résumé que
// rend seance.executer — ajoutés, mis à jour, inchangés, retirés,
// réservations retirées —, puis l'aperçu appliqué, ses lignes refusées
// remplacées par resume.refusees : celles de l'aperçu, puis celles de la
// mise à jour, chacune à son rang, avec les personnes qu'elle désigne. Un
// import qui n'a rien écrit le dit.
// Le rapport d'un import appliqué (§ 10.1, § 10.4) : les comptes du résumé
// que rend seance.executer, ceux de sa nature (comptesDuRapport) — pour des
// participants, ajoutés, mis à jour, inchangés, retirés, réservations
// retirées ; pour des objectifs, ajoutés, remplacés, inchangés, retirés,
// tables dont les objectifs changent —, puis l'aperçu appliqué, ses lignes
// refusées remplacées par resume.refusees : celles de l'aperçu, puis celles
// de la mise à jour, chacune à son rang, avec les personnes qu'elle
// désigne. Un import qui n'a rien écrit le dit.
//
// Le rapport est une carte titrée ; ses comptes, des chiffres tabulaires
// en tête de chaque libellé ; un import qui n'a rien écrit le dit en
// information (Message). Ses commandes, celles de la section, viennent
// en pied de carte (commandes, un snippet).
//
// rapport : { modele, source, resume, libelle } ; participants : ceux de
// la charge écrite.
// rapport : { nature, modele, source, resume, libelle } ; participants :
// ceux de la charge écrite.
import { getContext } from 'svelte';
import Carte from '../primitives/Carte.svelte';
import Apercu from './Apercu.svelte';
import Message from './Message.svelte';
import { refuseesDe } from './modele.js';
import { comptesDuRapport, refuseesDe } from './modele.js';
let { rapport, participants, commandes = null } = $props();
const t = getContext('t');
const format = getContext('format');
// Chaque compte du résumé et la clé de son libellé, dans l'ordre du résumé.
const COMPTES = [
['ajoutes', 'import.rapport.ajoutes'],
['misAJour', 'import.rapport.misAJour'],
['inchanges', 'import.rapport.inchanges'],
['retires', 'import.rapport.retires'],
['reservationsRetirees', 'import.rapport.reservationsRetirees'],
];
// Ce qu'un import qui n'a rien écrit dit, par nature.
const RIEN_ECRIT = new Map([
['participants', 'import.rapport.rienEcrit'],
['objectifs', 'import.rapport.rienEcritObjectifs'],
]);
const comptes = $derived(comptesDuRapport(rapport.nature));
const refusees = $derived(refuseesDe(rapport.resume.refusees));
</script>
@ -43,10 +43,10 @@
<Carte titre={t('import.rapport.titre')} niveau={3}>
<div class="contenu">
{#if rapport.libelle === null}
<Message bloc="rienEcrit"><p>{t('import.rapport.rienEcrit')}</p></Message>
<Message bloc="rienEcrit"><p>{t(RIEN_ECRIT.get(rapport.nature))}</p></Message>
{/if}
<ul class="comptes">
{#each COMPTES as [nom, cle] (nom)}
{#each comptes as { nom, cle } (nom)}
<li data-compte={nom}>
<span class="nombre" data-nombre>{format.nombre(rapport.resume[nom])}</span>
<span>{t(cle, { n: rapport.resume[nom] })}</span>

View file

@ -2,24 +2,32 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La section de l'import des participants (§ 10.1, § 10.2, § 2.1 étape
// 2). Un fichier choisi par le dialogue de la façade, un fichier déposé
// sur la fenêtre, ou un texte que le collage de la grille remet par
// ui.ouvrirImport, ouvre l'aperçu ; rien ne s'importe hors de lui.
// L'aperçu se calcule par apercevoir contre les participants et les
// tables de la charge — les étiquettes des objectifs donnent leur
// orthographe à celles du fichier —, sous le mode choisi et le choix des
// colonnes ambiguës : il n'écrit rien, et s'ouvre en lecture comme sur un
// plan bloqué. Seule
// « Importer » écrit, par seance.executer('importerParticipants', …), et
// son refus se pose à côté d'elle ; « Remplacer » passe d'abord par la
// fenêtre à confirmer, qui porte les quatre comptes de bilanRemplacement,
// et ne l'ouvre que sur un import que la commande accepterait.
// Le rapport lit le résumé que la séance rend.
// La section d'un import, § 10.1, § 10.2, § 10.4 et § 2.1 étape 2 : des
// participants, ou des objectifs de table, selon la nature que
// l'opérateur coche avant l'aperçu — Participants par défaut. La nature
// ne se devine jamais de l'en-tête : un fichier lu sous la mauvaise se
// refuse, et l'opérateur la change. Un fichier choisi par le dialogue de
// la façade, un fichier déposé sur la fenêtre, ou un texte que le collage
// de la grille remet par ui.ouvrirImport, ouvre l'aperçu ; rien ne
// s'importe hors de lui. Le collage vient de la grille des participants,
// et coche leur nature.
//
// L'aperçu se calcule par apercevoirSelon (modele.js) contre la charge,
// sous la nature, le mode choisi et le choix des colonnes ambiguës : il
// n'écrit rien, et s'ouvre en lecture comme sur un plan bloqué. Changer
// de nature recalcule l'aperçu du même texte, le choix des colonnes et le
// mode repartant de rien. Seule « Importer » écrit, par seance.executer —
// importerParticipants ou importerObjectifs —, et son refus se pose à
// côté d'elle ; « Remplacer » passe d'abord par la fenêtre à confirmer,
// qui porte les quatre comptes de bilanRemplacement pour des
// participants, les deux de bilanRemplacementObjectifs — objectifs et
// tables — pour des objectifs, et ne l'ouvre que sur un import que la
// commande accepterait. Le rapport lit le résumé que la séance rend.
//
// La section est une carte titrée, dont l'en-tête porte la consigne et
// « Choisir un fichier CSV… », son action principale ; l'aperçu, puis le
// rapport, sont des cartes sous elle.
// « Choisir un fichier CSV… », son action principale, et dont le contenu
// est le choix de la nature ; l'aperçu, puis le rapport, sont des cartes
// sous elle.
//
// Le CSV des refus — de l'aperçu, ou du résumé dans le rapport — vient
// d'exporterRefus de src/application/csv.js, dont le motif est celui de
@ -28,13 +36,27 @@
// et corrige le fichier.
import { getContext, untrack } from 'svelte';
import { appliquerCommande } from '../../application/commandes.js';
import { apercevoir, bilanRemplacement, decoder, exporterRefus } from '../../application/csv.js';
import { bilanRemplacement, bilanRemplacementObjectifs, decoder, exporterRefus } from '../../application/csv.js';
import Carte from '../primitives/Carte.svelte';
import Commande from '../primitives/Commande.svelte';
import Confirmation from '../primitives/Confirmation.svelte';
import Apercu from './Apercu.svelte';
import Rapport from './Rapport.svelte';
import { MODES_D_IMPORT, avecEntetesDeLaGrille, modeleApercu, modePropose } from './modele.js';
import {
NATURES_D_IMPORT,
NATURE_PAR_DEFAUT,
apercevoirSelon,
avecEntetesDeLaGrille,
modeleApercu,
modePropose,
modesDImport,
} from './modele.js';
// La commande qu'applique « Importer », par nature.
const COMMANDES_DES_NATURES = new Map([
['participants', 'importerParticipants'],
['objectifs', 'importerObjectifs'],
]);
const t = getContext('t');
const seance = getContext('seance');
@ -45,38 +67,55 @@
// La source de l'aperçu : { origine: 'fichier'|'collage', nom, encodage,
// texte, refusDecodage } ; texte null sous un refus de decoder.
let source = $state.raw(null);
// La nature cochée : ce que le fichier importe.
let nature = $state(NATURE_PAR_DEFAUT);
// { champ: rang } qui tranche une ambiguïté de l'en-tête.
let choix = $state.raw({});
let mode = $state('ajouter');
// Le dernier import appliqué : { modele, source, apercu, resume, libelle }.
// Le dernier import appliqué : { nature, modele, source, apercu, resume, libelle }.
let rapport = $state.raw(null);
// La fenêtre de « Remplacer » ouverte : { apercu, elements }.
// La fenêtre de « Remplacer » ouverte : { titre, nature, apercu, depuis, elements }.
let confirmation = $state.raw(null);
// Le chemin où le CSV des refus vient d'être enregistré.
let enregistre = $state(null);
const participants = $derived($seance.charge?.participants ?? []);
const tables = $derived($seance.charge?.tables ?? []);
const charge = $derived($seance.charge ?? null);
const participants = $derived(charge?.participants ?? []);
const apercu = $derived(
source === null || source.refusDecodage !== null ? null : apercevoir(source.texte, { participants, tables, choix, mode }),
source === null || source.refusDecodage !== null || charge === null
? null
: apercevoirSelon(nature, source.texte, charge, { choix, mode }),
);
const modele = $derived(
source === null ? null : modeleApercu({ encodage: source.encodage, apercu, refusDecodage: source.refusDecodage }),
source === null ? null : modeleApercu({ nature, encodage: source.encodage, apercu, refusDecodage: source.refusDecodage }),
);
// Le texte relu dans l'ordre de la grille, offert à un collage seul.
const grille = $derived(source?.origine === 'collage' && apercu !== null ? avecEntetesDeLaGrille(source.texte, apercu) : null);
const grille = $derived(
source?.origine === 'collage' && apercu !== null ? avecEntetesDeLaGrille(source.texte, apercu, nature) : null,
);
// Une source neuve : le choix des colonnes repart de rien, le mode
// proposé suit la liste, le rapport précédent se retire.
function ouvrirSource(nouvelle) {
source = nouvelle;
// Ce qui suit un aperçu neuf : le choix des colonnes repart de rien, le
// mode proposé suit la nature et la liste, le rapport précédent se
// retire.
function repartir() {
choix = {};
mode = modePropose(untrack(() => participants));
mode = modePropose(untrack(() => participants), untrack(() => nature));
rapport = null;
confirmation = null;
enregistre = null;
}
function ouvrirSource(nouvelle) {
source = nouvelle;
repartir();
}
// Une autre nature : l'aperçu ouvert se recalcule sur le même texte.
function choisirNature(nouvelle) {
nature = nouvelle;
repartir();
}
// Les octets d'un fichier : leur texte décodé, ou le refus de decoder,
// qui tient lieu d'aperçu. Toute autre erreur est une faute, qui remonte.
function lireFichier({ nom, octets }) {
@ -113,7 +152,9 @@
if ($ui.importEnAttente === null) return;
untrack(() => {
const pris = ui.prendreImport();
if (pris !== null) ouvrirSource({ origine: 'collage', nom: null, encodage: null, texte: pris.texte, refusDecodage: null });
if (pris === null) return;
nature = 'participants';
ouvrirSource({ origine: 'collage', nom: null, encodage: null, texte: pris.texte, refusDecodage: null });
});
});
@ -131,12 +172,13 @@
enregistre = null;
}
// L'aperçu appliqué par la séance, dans son mode ; le rapport lit le
// résumé rendu, et l'aperçu se ferme.
async function appliquer(applique, sonMode, depuis) {
const { libelle, resume } = await seance.executer('importerParticipants', { apercu: applique, mode: sonMode });
// L'aperçu appliqué par la séance, par la commande de sa nature, dans son
// mode ; le rapport lit le résumé rendu, et l'aperçu se ferme.
async function appliquer(applique, sonMode, depuis, saNature) {
const { libelle, resume } = await seance.executer(COMMANDES_DES_NATURES.get(saNature), { apercu: applique, mode: sonMode });
rapport = {
modele: modeleApercu({ encodage: depuis.encodage, apercu: applique }),
nature: saNature,
modele: modeleApercu({ nature: saNature, encodage: depuis.encodage, apercu: applique }),
source: { origine: depuis.origine, nom: depuis.nom },
apercu: applique,
resume,
@ -159,17 +201,32 @@
const applique = apercu;
const sonMode = mode;
const depuis = source;
const saNature = nature;
if (sonMode !== 'remplacer') {
await appliquer(applique, sonMode, depuis);
await appliquer(applique, sonMode, depuis, saNature);
return;
}
const { refus, charge } = seance.etat();
const { refus, charge: courante } = seance.etat();
if (refus !== null) throw refus;
appliquerCommande('importerParticipants', charge, { apercu: applique, mode: sonMode });
const bilan = bilanRemplacement(charge);
confirmation = {
apercu: applique,
depuis,
appliquerCommande(COMMANDES_DES_NATURES.get(saNature), courante, { apercu: applique, mode: sonMode });
confirmation = { nature: saNature, apercu: applique, depuis, ...annonceDuRemplacement(saNature, courante) };
}
// Le titre de la fenêtre de « Remplacer » et ce qu'elle nomme détruit :
// les personnes, réservations, titres pourvus et propositions pour des
// participants ; les objectifs et les tables qui en portent pour des
// objectifs.
function annonceDuRemplacement(saNature, courante) {
if (saNature === 'objectifs') {
const bilan = bilanRemplacementObjectifs(courante);
return {
titre: 'import.remplacer.titreObjectifs',
elements: [t('import.remplacer.objectifs', { n: bilan.objectifs }), t('import.remplacer.tables', { n: bilan.tables })],
};
}
const bilan = bilanRemplacement(courante);
return {
titre: 'import.remplacer.titre',
elements: [
t('import.remplacer.participants', { n: bilan.participants }),
t('import.remplacer.reservations', { n: bilan.reservations }),
@ -180,7 +237,7 @@
}
async function confirmerRemplacement() {
await appliquer(confirmation.apercu, 'remplacer', confirmation.depuis);
await appliquer(confirmation.apercu, 'remplacer', confirmation.depuis, confirmation.nature);
confirmation = null;
}
@ -197,6 +254,21 @@
{#snippet actions()}
<Commande cle="import.commande.choisirFichier" nom="choisirFichier" variante="principale" executer={choisirFichier} />
{/snippet}
<fieldset class="natures" data-nature-import>
<legend>{t('import.nature.titre')}</legend>
{#each NATURES_D_IMPORT as { nature: valeur, cle } (valeur)}
<label>
<input
type="radio"
name={`${uid}-nature`}
value={valeur}
checked={nature === valeur}
onchange={() => choisirNature(valeur)}
/>
<span>{t(cle)}</span>
</label>
{/each}
</fieldset>
</Carte>
{#if source !== null}
@ -207,7 +279,7 @@
{#if modele.global === null}
<fieldset class="modes" data-champ="mode">
<legend>{t('import.mode.titre')}</legend>
{#each MODES_D_IMPORT as { mode: valeur, cle } (valeur)}
{#each modesDImport(nature) as { mode: valeur, cle } (valeur)}
<label>
<input type="radio" name={`${uid}-mode`} value={valeur} bind:group={mode} />
<span>{t(cle)}</span>
@ -265,7 +337,7 @@
{#if confirmation !== null}
<Confirmation
titre="import.remplacer.titre"
titre={confirmation.titre}
elements={confirmation.elements}
surConfirmer={confirmerRemplacement}
surAnnuler={() => (confirmation = null)}
@ -297,7 +369,8 @@
gap: var(--espace-2);
}
.modes {
.modes,
.natures {
display: flex;
flex-wrap: wrap;
gap: var(--espace-3);
@ -312,4 +385,11 @@
padding: 0 var(--espace-1);
font-weight: bold;
}
/* Le choix de la nature et celui du mode se font aussi au clavier : le
bouton radio qui a le focus porte l'anneau de l'application. */
input:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
</style>

View file

@ -1,13 +1,39 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le modèle de l'aperçu d'import (§ 10.1, § 10.2) : ce que l'aperçu montre,
// en clés de libellés et en données, d'un fichier décodé, d'un fichier que
// decoder refuse, ou d'un collage. Le texte vient de la traduction ; ce
// module ne choisit que les clés, et ne recalcule rien de ce que l'aperçu de
// src/csv établit : association, lignes, fusions et doublons s'y lisent tels
// quels.
import { CHAMPS, JOINTURE_ETIQUETTES } from '../../application/csv.js';
// Le modèle de l'aperçu d'un import, § 10.1, § 10.2 et § 10.4 : ce que
// l'aperçu montre, en clés de libellés et en données, d'un fichier décodé,
// d'un fichier que decoder refuse, ou d'un collage, sous la nature que
// l'opérateur a choisie — des participants ou des objectifs de table. Le
// texte vient de la traduction ; ce module ne choisit que les clés, et ne
// recalcule rien de ce que l'aperçu de src/csv établit : association,
// lignes, fusions et doublons s'y lisent tels quels.
//
// La nature se reçoit, jamais ne se déduit du texte : une liste de
// participants porte souvent une colonne « Table » d'une soirée passée, et
// un en-tête d'objectifs lu sous Participants reste refusé, jamais lu comme
// des objectifs.
import { CHAMPS, CHAMPS_OBJECTIFS, apercevoir, apercevoirObjectifs } from '../../application/csv.js';
/** Les deux natures d'un fichier importé (§ 10.4), dans l'ordre où l'écran
* les offre, et la clé de leur nom. */
export const NATURES_D_IMPORT = Object.freeze(
[
['participants', 'import.nature.participants'],
['objectifs', 'import.nature.objectifs'],
].map(([nature, cle]) => Object.freeze({ nature, cle })),
);
/** La nature cochée à l'ouverture de la section : les participants. */
export const NATURE_PAR_DEFAUT = 'participants';
// La nature reçue, ou RangeError qui la nomme : jamais une nature devinée.
function exigerNature(nature) {
if (!NATURES_D_IMPORT.some((offerte) => offerte.nature === nature)) {
throw new RangeError(`import : nature inconnue ${JSON.stringify(nature)}`);
}
return nature;
}
/** La clé du nom de chaque encodage que rend decoder, dans l'ordre de ses
* quatre temps. */
@ -28,7 +54,8 @@ export const SEPARATEURS = new Map([
[null, 'import.separateur.uneColonne'],
]);
// La clé du nom de chaque champ, dans l'ordre de CHAMPS.
// La clé du nom de chaque champ, dans l'ordre de CHAMPS, puis de
// CHAMPS_OBJECTIFS : aucun nom n'est commun aux deux.
const CLES_DES_CHAMPS = new Map([
['nom', 'import.champ.nom'],
['prenom', 'import.champ.prenom'],
@ -38,6 +65,18 @@ const CLES_DES_CHAMPS = new Map([
['exclu', 'import.champ.exclu'],
['notes', 'import.champ.notes'],
['etiquettes', 'import.champ.etiquettes'],
['table', 'import.champ.table'],
['tour', 'import.champ.tour'],
['objectif', 'import.champ.objectif'],
['regle', 'import.champ.regle'],
]);
// La clé de la règle lue d'un objectif ; null : l'objectif suit la règle par
// défaut de l'événement.
const CLES_DE_REGLE = new Map([
['ou', 'import.regle.ou'],
['et', 'import.regle.et'],
[null, 'import.regle.defaut'],
]);
// La clé de la valeur lue d'« exclu » ; une cellule vide n'en a pas.
@ -49,15 +88,55 @@ const CLES_D_EXCLU = new Map([
// Le nom de chaque champ dans ChampsLus, celui de l'aperçu de src/csv.
const CHAMPS_LUS = new Map([['titre_pressenti', 'titrePressenti']]);
/** Les trois modes de l'import (§ 10.1), dans l'ordre où l'écran les
* offre, et la clé de leur nom. */
export const MODES_D_IMPORT = Object.freeze(
[
['ajouter', 'import.mode.ajouter'],
['mettreAJour', 'import.mode.mettreAJour'],
['remplacer', 'import.mode.remplacer'],
].map(([mode, cle]) => Object.freeze({ mode, cle })),
);
// Des modes et la clé de leur nom, figés.
const modes = (paires) => Object.freeze(paires.map(([mode, cle]) => Object.freeze({ mode, cle })));
/** Les trois modes de l'import des participants (§ 10.1), dans l'ordre où
* l'écran les offre, et la clé de leur nom. */
export const MODES_D_IMPORT = modes([
['ajouter', 'import.mode.ajouter'],
['mettreAJour', 'import.mode.mettreAJour'],
['remplacer', 'import.mode.remplacer'],
]);
// Les deux modes de l'import des objectifs (§ 10.4) : ajouter n'en est pas
// un, un couple (table, tour) déjà pourvu y serait une collision.
const MODES_D_IMPORT_OBJECTIFS = modes([
['mettreAJour', 'import.mode.mettreAJour'],
['remplacer', 'import.mode.remplacer'],
]);
/**
* Les modes qu'offre l'import d'une nature, dans l'ordre de l'écran.
*
* @param {'participants'|'objectifs'} nature
* @returns {ReadonlyArray<{mode: string, cle: string}>}
* @throws {RangeError} une nature inconnue
*/
export function modesDImport(nature) {
return exigerNature(nature) === 'participants' ? MODES_D_IMPORT : MODES_D_IMPORT_OBJECTIFS;
}
/**
* L'aperçu d'un texte décodé sous la nature choisie, contre la charge : celui
* des participants (apercevoir, contre ses personnes et ses tables, dont les
* objectifs donnent leur orthographe aux étiquettes) ou celui des objectifs
* de table (apercevoirObjectifs). Rien ne s'écrit.
*
* @param {'participants'|'objectifs'} nature
* @param {string} texte
* @param {Object} charge la charge de l'événement ouvert
* @param {{choix?: Object<string, number>, mode?: string}} [options] le
* choix des colonnes ambiguës et le mode ; chaque aperçu prend son mode
* par défaut quand mode manque
* @returns {Object} l'aperçu de la nature, tel que src/csv le rend
* @throws {RangeError} une nature inconnue
*/
export function apercevoirSelon(nature, texte, charge, { choix = {}, mode } = {}) {
const options = mode === undefined ? { choix } : { choix, mode };
if (exigerNature(nature) === 'objectifs') return apercevoirObjectifs(texte, charge, options);
return apercevoir(texte, { participants: charge.participants, tables: charge.tables, ...options });
}
/** Les trois sortes d'un message de l'aperçu et du rapport, chacune un ton
* de pastille (Pastille, sorte 'statut'). */
@ -76,7 +155,8 @@ const ICONES_DE_SORTE = new Map([
// que le logiciel ne fusionne jamais seul — doublons, homonymes d'une mise
// à jour —, ou une cellule sur plusieurs lignes qui porte le séparateur,
// peut-être un guillemet mal fermé ; une information dit ce qui a été fait
// ou lu sans rien écarter.
// ou lu sans rien écarter — les orthographes fondues d'appartenances et
// celles d'étiquettes, deux blocs distincts.
const SORTES_DES_BLOCS = new Map([
['global', 'refus'],
['refusees', 'refus'],
@ -84,6 +164,7 @@ const SORTES_DES_BLOCS = new Map([
['homonymes', 'avis'],
['multilignes', 'avis'],
['fusions', 'information'],
['fusionsEtiquettes', 'information'],
['rienEcrit', 'information'],
]);
@ -91,7 +172,7 @@ const SORTES_DES_BLOCS = new Map([
* La classe d'un bloc de messages de l'aperçu ou du rapport.
*
* @param {string} bloc 'global', 'refusees', 'doublons', 'homonymes',
* 'fusions', 'multilignes' ou 'rienEcrit'
* 'fusions', 'fusionsEtiquettes', 'multilignes' ou 'rienEcrit'
* @returns {{sorte: string, cle: string, icone: string}} la sorte, la clé
* de son libellé et l'icône de sa pastille
* @throws {RangeError} un bloc inconnu
@ -102,16 +183,61 @@ export function messageDuBloc(bloc) {
return { sorte, cle: `import.sorte.${sorte}`, icone: ICONES_DE_SORTE.get(sorte) };
}
/** Les colonnes du tableau des lignes : chaque champ, dans l'ordre de
* CHAMPS, et la clé de son nom. */
export const COLONNES_DES_LIGNES = Object.freeze(CHAMPS.map((champ) => Object.freeze({ champ, cle: CLES_DES_CHAMPS.get(champ) })));
// Des champs et la clé de leur nom, figés.
const colonnes = (champs) => Object.freeze(champs.map((champ) => Object.freeze({ champ, cle: CLES_DES_CHAMPS.get(champ) })));
/** Les colonnes du tableau des lignes des participants : chaque champ, dans
* l'ordre de CHAMPS, et la clé de son nom. */
export const COLONNES_DES_LIGNES = colonnes(CHAMPS);
/** Les colonnes du tableau des lignes des objectifs : chaque champ, dans
* l'ordre de CHAMPS_OBJECTIFS, et la clé de son nom. */
export const COLONNES_DES_OBJECTIFS = colonnes(CHAMPS_OBJECTIFS);
// Les comptes du rapport de chaque nature, dans l'ordre du résumé de son
// import, et la clé de leur libellé, accordé au nombre.
const COMPTES_DU_RAPPORT = new Map([
[
'participants',
[
['ajoutes', 'import.rapport.ajoutes'],
['misAJour', 'import.rapport.misAJour'],
['inchanges', 'import.rapport.inchanges'],
['retires', 'import.rapport.retires'],
['reservationsRetirees', 'import.rapport.reservationsRetirees'],
],
],
[
'objectifs',
[
['ajoutes', 'import.rapport.objectifsAjoutes'],
['remplaces', 'import.rapport.objectifsRemplaces'],
['inchanges', 'import.rapport.objectifsInchanges'],
['retires', 'import.rapport.objectifsRetires'],
['tables', 'import.rapport.tablesTouchees'],
],
],
]);
/**
* Les comptes que montre le rapport d'un import : chaque nom du résumé de
* la commande de la nature, et la clé de son libellé.
*
* @param {'participants'|'objectifs'} nature
* @returns {Array<{nom: string, cle: string}>}
* @throws {RangeError} une nature inconnue
*/
export function comptesDuRapport(nature) {
return COMPTES_DU_RAPPORT.get(exigerNature(nature)).map(([nom, cle]) => ({ nom, cle }));
}
// Le remède d'un refus de decoder : le code que ses détails portent, que le
// texte du refus compose déjà (contrat « Refus et avertissements »).
const REFUS_DE_DECODER = new Set(['UTF16_SANS_MARQUE', 'UTF16_INVALIDE', 'UTF8_INVALIDE', 'CARACTERE_NUL']);
// La clé du remède de chaque refus global de l'aperçu. Un caractère nul n'y
// arrive que d'un texte collé : celui d'un fichier, decoder le refuse.
// La clé du remède de chaque refus global de l'aperçu des participants. Un
// caractère nul n'y arrive que d'un texte collé : celui d'un fichier,
// decoder le refuse.
const REMEDES_DE_L_APERCU = new Map([
['CARACTERE_NUL', 'import.remede.caractereNulCollage'],
['AUCUN_ENTETE', 'import.remede.aucunEntete'],
@ -121,6 +247,26 @@ const REMEDES_DE_L_APERCU = new Map([
['SEPARATEUR_INTROUVABLE', 'import.remede.separateurIntrouvable'],
]);
// La clé du remède de chaque refus global de l'aperçu des objectifs (§ 10.4) :
// ceux du § 10.1 hors NOM_NON_ASSOCIE, et les trois qui lui sont propres.
// OBJECTIFS_SANS_TABLE n'en a pas : son texte dit déjà de créer les tables,
// et son remède est le geste qui ouvre leur section.
const REMEDES_DES_OBJECTIFS = new Map([
['CARACTERE_NUL', 'import.remede.caractereNulCollage'],
['AUCUN_ENTETE', 'import.remede.aucunEnteteObjectifs'],
['TABLE_NON_ASSOCIEE', 'import.remede.tableNonAssociee'],
['OBJECTIF_NON_ASSOCIE', 'import.remede.objectifNonAssocie'],
['OBJECTIFS_SANS_TABLE', null],
['AUCUNE_LIGNE_VALIDE', 'import.remede.aucuneLigneValide'],
['GUILLEMET_OUVERT', 'import.remede.guillemetOuvert'],
['SEPARATEUR_INTROUVABLE', 'import.remede.separateurIntrouvable'],
]);
// Le geste qui remédie à un refus global des objectifs, celui que la
// commande importerObjectifs pose sur le même code : la section des tables
// pour un événement sans table.
const GESTES_DES_OBJECTIFS = new Map([['OBJECTIFS_SANS_TABLE', Object.freeze({ geste: 'ouvrirSection', section: 'tables' })]]);
// Le remède d'un collage auquel l'aperçu offre l'ordre de la grille.
const REMEDES_DE_LA_GRILLE = new Map([
['AUCUN_ENTETE', 'import.remede.aucunEnteteCollage'],
@ -135,8 +281,10 @@ function dans(table, valeur, quoi) {
}
// Vrai quand l'aperçu offre la lecture dans l'ordre de la grille : un texte
// non blanc sans en-tête reconnu, ou une seule colonne sans séparateur.
function offreLaGrille(apercu) {
// non blanc sans en-tête reconnu, ou une seule colonne sans séparateur, lu
// comme une liste de participants. La grille n'a pas l'ordre des objectifs.
function offreLaGrille(apercu, nature = 'participants') {
if (nature !== 'participants') return false;
if (apercu.refusGlobal === 'AUCUN_ENTETE') return apercu.entetes.length > 0;
return apercu.refusGlobal === 'SEPARATEUR_INTROUVABLE' && apercu.refusGlobalLigne === null;
}
@ -145,18 +293,24 @@ function offreLaGrille(apercu) {
// le code de details.remede, déjà dans la cause.
function globalDeDecoder({ code, details }) {
if (!REFUS_DE_DECODER.has(code)) throw new RangeError(`import : refus de decoder inconnu ${JSON.stringify(code)}`);
return { cle: code, details, remede: { cle: details.remede, details: {}, dansLaCause: true } };
return { cle: code, details, remede: { cle: details.remede, details: {}, dansLaCause: true, geste: null } };
}
// Le refus global d'un aperçu, null sans refus. Un collage auquel l'aperçu
// offre l'ordre de la grille prend le remède qui le nomme.
function globalDeLApercu(apercu, collage) {
// offre l'ordre de la grille prend le remède qui le nomme. Un refus que son
// texte remédie déjà porte le seul geste qui y mène.
function globalDeLApercu(apercu, collage, nature) {
const code = apercu.refusGlobal;
if (code === null) return null;
const remede = collage && offreLaGrille(apercu) ? REMEDES_DE_LA_GRILLE.get(code) : dans(REMEDES_DE_L_APERCU, code, 'refus global');
const rang = apercu.refusGlobalLigne;
const details = rang === null ? {} : { ligne: rang };
return { cle: code, details, remede: { cle: remede, details, dansLaCause: false } };
if (nature === 'objectifs') {
const remede = dans(REMEDES_DES_OBJECTIFS, code, 'refus global des objectifs');
const geste = GESTES_DES_OBJECTIFS.get(code) ?? null;
return { cle: code, details, remede: { cle: remede, details, dansLaCause: remede === null, geste } };
}
const remede = collage && offreLaGrille(apercu) ? REMEDES_DE_LA_GRILLE.get(code) : dans(REMEDES_DE_L_APERCU, code, 'refus global');
return { cle: code, details, remede: { cle: remede, details, dansLaCause: false, geste: null } };
}
// Le nom du séparateur, quand la lecture l'a établi : rien sous un caractère
@ -171,10 +325,10 @@ function separateurDe(apercu) {
}
// Chaque colonne de l'en-tête : son rang, son en-tête tel qu'écrit, et la
// clé de ce qu'elle devient — un champ, une colonne à trancher, une colonne
// écartée par le choix d'une autre, une colonne non reconnue.
function colonnesDe({ entetes, association }) {
const champDuRang = new Map(CHAMPS.filter((champ) => Object.hasOwn(association.colonnes, champ)).map((champ) => [association.colonnes[champ], champ]));
// clé de ce qu'elle devient — un champ de la nature, une colonne à trancher,
// une colonne écartée par le choix d'une autre, une colonne non reconnue.
function colonnesDe({ entetes, association }, champs) {
const champDuRang = new Map(champs.filter((champ) => Object.hasOwn(association.colonnes, champ)).map((champ) => [association.colonnes[champ], champ]));
const aTrancher = new Set(association.ambigus.flatMap(({ rangs }) => rangs));
const nonReconnues = new Set(association.nonReconnues);
return entetes.map((entete, rang) => {
@ -186,21 +340,40 @@ function colonnesDe({ entetes, association }) {
});
}
// Chaque ligne valide : son rang, puis une cellule par champ, dans l'ordre
// de CHAMPS : le texte lu, ou la clé de la valeur d'« exclu » ; les
// étiquettes jointes comme l'export les joint, null sans étiquette.
function lignesDe(lignes) {
// Une cellule de l'aperçu : un texte lu, une clé de libellé, ou une liste
// d'étiquettes, chacune une pastille ; les deux autres null.
const cellule = (champ, { texte = null, cle = null, etiquettes = null }) => ({ champ, texte, cle, etiquettes });
// Chaque ligne valide des participants : son rang, puis une cellule par
// champ, dans l'ordre de CHAMPS : le texte lu, ou la clé de la valeur
// d'« exclu » ; les étiquettes une à une, aucune pour une cellule vide.
function lignesDesParticipants(lignes) {
return lignes.map(({ ligne, champs }) => ({
ligne,
cellules: CHAMPS.map((champ) => {
const valeur = champs[CHAMPS_LUS.get(champ) ?? champ];
if (champ === 'exclu') return { champ, texte: null, cle: valeur === null ? null : CLES_D_EXCLU.get(valeur) };
if (champ === 'etiquettes') return { champ, texte: valeur === null ? null : valeur.join(JOINTURE_ETIQUETTES), cle: null };
return { champ, texte: valeur, cle: null };
if (champ === 'exclu') return cellule(champ, { cle: valeur === null ? null : CLES_D_EXCLU.get(valeur) });
if (champ === 'etiquettes') return cellule(champ, { etiquettes: valeur ?? [] });
return cellule(champ, { texte: valeur });
}),
}));
}
// Chaque ligne valide des objectifs : son rang, puis le numéro affiché de la
// table, le tour — la clé de la soirée pour un tour null —, les étiquettes
// de l'objectif une à une, et la clé de sa règle.
function lignesDesObjectifs(lignes) {
return lignes.map(({ ligne, champs }) => ({
ligne,
cellules: [
cellule('table', { texte: String(champs.numero) }),
champs.tour === null ? cellule('tour', { cle: 'import.tour.soiree' }) : cellule('tour', { texte: String(champs.tour) }),
cellule('objectif', { etiquettes: champs.etiquettes }),
cellule('regle', { cle: CLES_DE_REGLE.get(champs.regle) }),
],
}));
}
/**
* Les lignes refusées, par l'aperçu ou par la mise à jour, telles que
* l'aperçu et le rapport les montrent : le rang, le code, et les détails que
@ -231,14 +404,17 @@ export function nommer(participants, ids) {
}
/**
* Le mode d'import proposé (§ 10.1) : Ajouter pour un événement sans
* participant, Mettre à jour sinon. Remplacer ne se propose jamais : il se
* choisit.
* Le mode d'import proposé (§ 10.1, § 10.4) : pour des participants, Ajouter
* dans un événement sans participant, Mettre à jour sinon ; pour des
* objectifs, Mettre à jour. Remplacer ne se propose jamais : il se choisit.
*
* @param {Array<Object>} participants
* @param {'participants'|'objectifs'} [nature] participants par défaut
* @returns {'ajouter'|'mettreAJour'}
* @throws {RangeError} une nature inconnue
*/
export function modePropose(participants) {
export function modePropose(participants, nature = 'participants') {
if (exigerNature(nature) === 'objectifs') return 'mettreAJour';
return participants.length === 0 ? 'ajouter' : 'mettreAJour';
}
@ -252,6 +428,7 @@ const RIEN = Object.freeze({
lignes: [],
refusees: [],
fusions: [],
fusionsEtiquettes: [],
doublons: [],
homonymes: [],
multilignes: [],
@ -259,83 +436,107 @@ const RIEN = Object.freeze({
});
/**
* Ce que l'aperçu montre, d'un fichier ou d'un collage : encodage (null pour
* un collage) et séparateur nommés — « une seule colonne » pour un
* séparateur null —, association par colonne, ambiguïtés, lignes valides et
* refusées, fusions, doublons, et le refus global, de decoder (refusDecodage,
* apercu null) ou de l'aperçu (apercu.refusGlobal), avec la clé de sa cause
* et celle de son remède — en clés de libellés et en données.
* Ce que l'aperçu montre, d'un fichier ou d'un collage, sous sa nature :
* encodage (null pour un collage) et séparateur nommés — « une seule
* colonne » pour un séparateur null —, association par colonne, ambiguïtés,
* colonnes et lignes valides, lignes refusées, orthographes fondues
* d'appartenances et d'étiquettes, chacune dans son bloc, doublons, et le
* refus global, de decoder (refusDecodage, apercu null) ou de l'aperçu
* (apercu.refusGlobal), avec la clé de sa cause et celle de son remède — en
* clés de libellés et en données. Un aperçu des objectifs n'a ni
* appartenances, ni doublons, ni homonymes : ces listes y sont vides.
*
* La cause est le code du refus, sa propre clé ; ses détails sont ceux de
* decoder, ou { ligne } quand l'aperçu nomme un rang. Le remède d'un refus
* de decoder est le code de details.remede, que le texte de la cause compose
* déjà : remede.dansLaCause le dit, et l'écran ne le répète pas.
* déjà : remede.dansLaCause le dit, et l'écran ne le répète pas. remede.geste
* est le remède du contrat (commande.js) qui y mène, null sans geste.
*
* @param {{encodage?: string|null, apercu?: Object|null, refusDecodage?: {code: string, details: Object}|null}} entree
* @param {{nature?: 'participants'|'objectifs', encodage?: string|null, apercu?: Object|null,
* refusDecodage?: {code: string, details: Object}|null}} entree
* nature : celle que l'opérateur a choisie, participants par défaut ;
* encodage : celui que rend decoder, null pour un collage
* @returns {{
* encodage: string|null, separateur: string|null,
* champs: ReadonlyArray<{champ: string, cle: string}>,
* colonnes: Array<{rang: number, entete: string, cle: string}>,
* ambigus: Array<{champ: string, cle: string, rangs: Array<{rang: number, entete: string}>}>,
* valides: number,
* lignes: Array<{ligne: number, cellules: Array<{champ: string, texte: string|null, cle: string|null}>}>,
* lignes: Array<{ligne: number, cellules: Array<{champ: string, texte: string|null, cle: string|null, etiquettes: string[]|null}>}>,
* refusees: Array<{ligne: number, code: string, details: Object}>,
* fusions: Array<{affichee: string, orthographes: Array<{texte: string, lignes: number}>}>,
* fusionsEtiquettes: Array<{retenue: string, orthographes: string[], lignes: number}>,
* doublons: Array<{lignes: number[], existants: number[]}>,
* homonymes: Array<{ligne: number, participants: number[]}>,
* multilignes: Array<{ligne: number, rangs: number[]}>,
* global: null|{cle: string, details: Object, remede: {cle: string, details: Object, dansLaCause: boolean}},
* global: null|{cle: string, details: Object,
* remede: {cle: string|null, details: Object, dansLaCause: boolean, geste: Object|null}},
* }}
* encodage, separateur, colonnes[].cle et ambigus[].cle sont des clés de
* traduction, comme lignes[].cellules[].cle, celle de la valeur
* d'« exclu » ; refusees[].details, ceux que reçoit le motif d'une ligne
* (motifLigne) ; fusions[].orthographes[].lignes, le nombre de lignes de
* l'orthographe, 0 pour celle que seuls des participants existants portent
* @throws {RangeError} un encodage, un séparateur ou un refus sans clé
* champs : les colonnes du tableau des lignes de la nature ; encodage,
* separateur, colonnes[].cle et ambigus[].cle sont des clés de
* traduction, comme lignes[].cellules[].cle — la valeur d'« exclu », la
* soirée, la règle d'un objectif ; refusees[].details, ceux que reçoit le
* motif d'une ligne (motifLigne) ; fusions[].orthographes[].lignes, le
* nombre de lignes de l'orthographe, 0 pour celle que seuls des
* participants existants portent ; fusionsEtiquettes[].lignes, le nombre
* de lignes réécrites sous l'orthographe retenue
* @throws {RangeError} une nature, un encodage, un séparateur ou un refus
* sans clé
*/
export function modeleApercu({ encodage = null, apercu = null, refusDecodage = null }) {
if (refusDecodage !== null) return { ...RIEN, global: globalDeDecoder(refusDecodage) };
if (apercu === null) return { ...RIEN };
export function modeleApercu({ nature = 'participants', encodage = null, apercu = null, refusDecodage = null }) {
const objectifs = exigerNature(nature) === 'objectifs';
const champs = objectifs ? COLONNES_DES_OBJECTIFS : COLONNES_DES_LIGNES;
if (refusDecodage !== null) return { ...RIEN, champs, global: globalDeDecoder(refusDecodage) };
if (apercu === null) return { ...RIEN, champs };
const { entetes, association } = apercu;
return {
encodage: encodage === null ? null : dans(ENCODAGES, encodage, 'encodage'),
separateur: separateurDe(apercu),
colonnes: colonnesDe(apercu),
champs,
colonnes: colonnesDe(apercu, objectifs ? CHAMPS_OBJECTIFS : CHAMPS),
ambigus: association.ambigus.map(({ champ, rangs }) => ({
champ,
cle: CLES_DES_CHAMPS.get(champ),
rangs: rangs.map((rang) => ({ rang, entete: entetes[rang] })),
})),
valides: apercu.lignes.length,
lignes: lignesDe(apercu.lignes),
lignes: objectifs ? lignesDesObjectifs(apercu.lignes) : lignesDesParticipants(apercu.lignes),
refusees: refuseesDe(apercu.refusees),
fusions: apercu.fusions.map(({ affichee, orthographes }) => ({
fusions: (apercu.fusions ?? []).map(({ affichee, orthographes }) => ({
affichee,
orthographes: orthographes.map(({ texte, lignes }) => ({ texte, lignes: lignes.length })),
})),
doublons: apercu.doublons.map(({ lignes, existants }) => ({ lignes, existants })),
homonymes: apercu.homonymes,
fusionsEtiquettes: apercu.fusionsEtiquettes.map(({ retenue, orthographes, lignes }) => ({
retenue,
orthographes: [...orthographes],
lignes,
})),
doublons: (apercu.doublons ?? []).map(({ lignes, existants }) => ({ lignes, existants })),
homonymes: apercu.homonymes ?? [],
multilignes: apercu.multilignes,
global: globalDeLApercu(apercu, encodage === null),
global: globalDeLApercu(apercu, encodage === null, nature),
};
}
/**
* Le texte relu dans l'ordre de la grille : précédé d'une ligne d'en-têtes —
* Le texte relu dans l'ordre de la grille, sous la nature des participants
* seule : précédé d'une ligne d'en-têtes —
* les largeur premiers de CHAMPS, puis des en-têtes vides au-delà de huit —,
* joints par le séparateur de l'aperçu. Sous AUCUN_ENTETE d'un texte non
* blanc, largeur vaut apercu.entetes.length ; sous SEPARATEUR_INTROUVABLE
* sans candidat (refusGlobalLigne null : une seule colonne), largeur vaut 1
* et la ligne est « nom ». null sous tout autre aperçu. Offert pour un
* collage, jamais appliqué seul : le texte rendu repasse par apercevoir, et
* les rangs de ce second aperçu comptent la ligne ajoutée.
* les rangs de ce second aperçu comptent la ligne ajoutée. null sous la
* nature des objectifs : la grille n'en a pas l'ordre.
*
* @param {string} texte le texte de l'aperçu
* @param {Object} apercu l'aperçu de ce texte
* @param {'participants'|'objectifs'} [nature] participants par défaut
* @returns {string|null}
*/
export function avecEntetesDeLaGrille(texte, apercu) {
if (!offreLaGrille(apercu)) return null;
export function avecEntetesDeLaGrille(texte, apercu, nature = 'participants') {
if (!offreLaGrille(apercu, exigerNature(nature))) return null;
const largeur = apercu.refusGlobal === 'AUCUN_ENTETE' ? apercu.entetes.length : 1;
const entetes = Array.from({ length: largeur }, (_, rang) => CHAMPS[rang] ?? '');
return `${entetes.join(apercu.separateur ?? '')}\n${texte}`;

View file

@ -1,27 +1,35 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le modèle de l'aperçu d'import (§ 10.1, § 10.2) : la lecture d'un collage
// sans en-tête dans l'ordre de la grille, la clé de la cause et celle du
// remède de chaque refus global, l'encodage et le séparateur nommés, et les
// titres que « Remplacer » annonce, comptés par la règle unique des titres
// pourvus. Les noms d'épreuve sont inventés.
// Le modèle de l'aperçu d'un import, § 10.1, § 10.2 et § 10.4 : la lecture
// d'un collage sans en-tête dans l'ordre de la grille, la clé de la cause et
// celle du remède de chaque refus global, l'encodage et le séparateur
// nommés, les titres que « Remplacer » annonce, comptés par la règle unique
// des titres pourvus, la nature du fichier reçue et jamais devinée, les
// objectifs de table et les étiquettes fondues, à part des appartenances.
// Les noms d'épreuve sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { apercevoir, bilanRemplacement, CHAMPS, decoder } from '../../application/csv.js';
import { apercevoir, apercevoirObjectifs, bilanRemplacement, CHAMPS, CHAMPS_OBJECTIFS, decoder } from '../../application/csv.js';
import { comptesTitres, titresNonPourvus } from '../../application/listes_travail.js';
import { creerTraduction } from '../../application/traduction.js';
import { creerCharge } from '../../stockage/document.js';
import { TABLES_INTERFACE } from '../libelles.js';
import {
COLONNES_DES_LIGNES,
COLONNES_DES_OBJECTIFS,
ENCODAGES,
MODES_D_IMPORT,
NATURES_D_IMPORT,
NATURE_PAR_DEFAUT,
SEPARATEURS,
SORTES_DE_MESSAGE,
apercevoirSelon,
avecEntetesDeLaGrille,
comptesDuRapport,
messageDuBloc,
modesDImport,
modeleApercu,
modePropose,
motifLigne,
@ -315,7 +323,7 @@ describe('modeleApercu : ce que l’aperçu montre (§ 10.1)', () => {
});
describe('les lignes, les refus de la mise à jour, les personnes désignées, le mode proposé (§ 10.1)', () => {
test('chaque ligne valide montre ses huit champs dans l’ordre de CHAMPS ; exclu par sa clé, vide sans valeur ; les étiquettes jointes par « , »', () => {
test('chaque ligne valide montre ses huit champs dans l’ordre de CHAMPS ; exclu par sa clé, vide sans valeur ; les étiquettes une à une, chacune sa pastille', () => {
const texte = lignes(
'notes;exclu;nom;prenom;mots-clés',
'arrive tard;Oui;Ombrelle;Iris;',
@ -330,7 +338,9 @@ describe('les lignes, les refus de la mise à jour, les personnes désignées, l
for (const { cle } of COLONNES_DES_LIGNES) assert.ok(t(cle).length > 0, cle);
const [premiere, deuxieme, troisieme] = lues;
assert.deepEqual(premiere.cellules.map(({ texte: valeur }) => valeur), ['Ombrelle', 'Iris', null, null, null, null, 'arrive tard', null]);
assert.deepEqual(deuxieme.cellules.at(-1), { champ: 'etiquettes', texte: 'finance, Montréal', cle: null });
assert.deepEqual(deuxieme.cellules.at(-1), { champ: 'etiquettes', texte: null, cle: null, etiquettes: ['finance', 'Montréal'] });
assert.deepEqual(premiere.cellules.at(-1), { champ: 'etiquettes', texte: null, cle: null, etiquettes: [] });
for (const { champ, etiquettes } of premiere.cellules.slice(0, -1)) assert.equal(etiquettes, null, champ);
assert.equal(premiere.cellules[5].cle, 'import.exclu.oui');
assert.equal(deuxieme.cellules[5].cle, null);
assert.equal(troisieme.cellules[5].cle, 'import.exclu.non');
@ -426,8 +436,8 @@ describe('messageDuBloc : chaque message de l’aperçu et du rapport classé en
}
});
test('les informations : les orthographes fondues, un import qui n’a rien écrit', () => {
for (const bloc of ['fusions', 'rienEcrit']) {
test('les informations : les orthographes fondues, d’appartenance ou d’étiquette, un import qui n’a rien écrit', () => {
for (const bloc of ['fusions', 'fusionsEtiquettes', 'rienEcrit']) {
assert.deepEqual(messageDuBloc(bloc), { sorte: 'information', cle: 'import.sorte.information', icone: 'information' }, bloc);
}
});
@ -438,7 +448,7 @@ describe('messageDuBloc : chaque message de l’aperçu et du rapport classé en
test('trois sortes, chacune un ton de pastille, un libellé traduit et une icône distincte', () => {
assert.deepEqual(SORTES_DE_MESSAGE, ['refus', 'avis', 'information']);
const blocs = ['global', 'refusees', 'doublons', 'homonymes', 'fusions', 'multilignes', 'rienEcrit'].map(messageDuBloc);
const blocs = ['global', 'refusees', 'doublons', 'homonymes', 'fusions', 'fusionsEtiquettes', 'multilignes', 'rienEcrit'].map(messageDuBloc);
assert.equal(new Set(blocs.map(({ icone }) => icone)).size, 3);
assert.deepEqual(
SORTES_DE_MESSAGE.map((sorte) => t(`import.sorte.${sorte}`)),
@ -446,3 +456,294 @@ describe('messageDuBloc : chaque message de l’aperçu et du rapport classé en
);
});
});
// --- La nature du fichier, les objectifs de table, les étiquettes (§ 10.1, § 10.4) ---
// Une charge de deux tours et de neuf tables, numérotées 1, 2, 3, 3, puis 4
// à 8 : deux tables portent le numéro 3. Les personnes données, sans
// étiquette par défaut.
function chargeDesObjectifs({ participants = [], tables = null } = {}) {
const charge = creerCharge({ id: 'epreuve-objectifs', nom: 'Soirée des Merles', siegesParDefaut: 6, tours: 2 });
const numeros = [1, 2, 3, 3, 4, 5, 6, 7, 8];
charge.tables = tables ?? numeros.map((numero, rang) => ({ id: rang + 1, numero, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } }));
charge.participants = participants;
charge.prochainsIds = { participant: participants.length + 1, table: charge.tables.length + 1, proposition: 1 };
return charge;
}
// Une personne d'épreuve, ses étiquettes données.
const porteuse = (id, nom, etiquettes) => ({
id, nom, prenom: null, appartenance: null, courriel: null, titrePressenti: null, notes: null, exclu: false, etiquettes,
});
// Le CSV des objectifs qui refuse une ligne pour chaque motif du § 10.4,
// dans l'ordre du texte : [ligne, code]. Huit lignes valides de remplissage
// précèdent le champ en trop, que l'analyse du séparateur ne lit qu'au-delà
// de ses vingt premiers enregistrements. La ligne 15 écrit « FINANCE », que
// la réconciliation fond dans « finance ».
const VINGT_ET_UNE = Array.from({ length: 21 }, (_, rang) => `mot${rang + 1}`).join('|');
const TEXTE_DES_MOTIFS = lignes(
'table;tour;objectif;regle',
'1;;finance;',
';;finance;',
'un;;finance;',
'9;;finance;',
'3;;finance;',
'2;2.0;finance;',
'2;5;finance;',
'2;1;;',
`2;1;${'x'.repeat(41)};`,
`2;1;${VINGT_ET_UNE};`,
'2;1;finance;peut-être',
'1;;Finance;et',
'2;2;FINANCE;toutes',
'4;;bénévole;ou',
'4;1;bénévole;',
'4;2;bénévole;',
'5;;bénévole;',
'5;1;bénévole;',
'5;2;bénévole;',
'6;;bénévole;',
'6;1;bénévole;une',
'1;1;finance;;en trop',
);
const MOTIFS_ATTENDUS = [
[3, 'TABLE_ABSENTE'],
[4, 'TABLE_NON_ENTIERE'],
[5, 'NUMERO_TABLE_INCONNU'],
[6, 'PLUSIEURS_TABLES'],
[7, 'TOUR_NON_ENTIER'],
[8, 'TOUR_HORS_SOIREE'],
[9, 'OBJECTIF_VIDE'],
[10, 'ETIQUETTE_MAL_FORMEE'],
[11, 'ETIQUETTES_TROP_NOMBREUSES'],
[12, 'REGLE_INCONNUE'],
[13, 'OBJECTIF_EN_DOUBLE'],
[23, 'CHAMPS_EN_TROP'],
];
describe('la nature du fichier : choisie par l’opérateur, jamais devinée (§ 10.4)', () => {
test('deux natures, Participants d’abord et par défaut, chacune nommée par sa clé', () => {
assert.deepEqual(NATURES_D_IMPORT.map(({ nature }) => nature), ['participants', 'objectifs']);
assert.equal(NATURE_PAR_DEFAUT, 'participants');
assert.deepEqual(NATURES_D_IMPORT.map(({ cle }) => t(cle)), ['Participants', 'Objectifs de table']);
});
test('l’en-tête d’un fichier d’objectifs lu sous Participants : l’aperçu des participants, refusé faute d’en-tête reconnu, jamais celui des objectifs', () => {
const charge = chargeDesObjectifs();
const texte = lignes('table;tour;objectif;regle', '1;;finance;');
const lu = apercevoirSelon('participants', texte, charge);
assert.equal(lu.refusGlobal, 'AUCUN_ENTETE');
assert.deepEqual(lu, apercevoir(texte, { participants: charge.participants, tables: charge.tables }));
const objectifs = apercevoirSelon('objectifs', texte, charge);
assert.equal(objectifs.refusGlobal, null);
assert.deepEqual(objectifs, apercevoirObjectifs(texte, charge));
});
test('une liste de participants qui porte une colonne « Table » lue sous Objectifs : l’aperçu des objectifs, refusé faute d’objectif ; sous Participants, la table ignorée', () => {
const charge = chargeDesObjectifs();
const texte = lignes('nom;prenom;table', 'Ombrelle;Iris;2', 'Pervenche;Théo;1');
assert.equal(apercevoirSelon('objectifs', texte, charge).refusGlobal, 'OBJECTIF_NON_ASSOCIE');
const lu = apercevoirSelon('participants', texte, charge);
assert.equal(lu.refusGlobal, null);
assert.deepEqual(nomsLus(lu), ['Ombrelle', 'Pervenche']);
assert.deepEqual(lu.association.nonReconnues, [2]);
});
test('le choix des colonnes et le mode passent à l’aperçu de la nature ; une nature inconnue lève', () => {
const charge = chargeDesObjectifs();
const texte = lignes('table;objectif;objectifs', '1;finance;bénévole');
const ambigu = apercevoirSelon('objectifs', texte, charge, { mode: 'remplacer' });
assert.equal(ambigu.mode, 'remplacer');
assert.deepEqual(ambigu.association.ambigus, [{ champ: 'objectif', rangs: [1, 2] }]);
const tranche = apercevoirSelon('objectifs', texte, charge, { choix: { objectif: 2 }, mode: 'remplacer' });
assert.deepEqual(tranche.lignes.map(({ champs }) => champs.etiquettes), [['bénévole']]);
assert.equal(apercevoirSelon('participants', lignes('nom', 'Ombrelle'), charge, { mode: 'remplacer' }).mode, 'remplacer');
assert.throws(() => apercevoirSelon('tables', texte, charge), { name: 'RangeError', message: /"tables"/ });
});
test('les modes de chaque nature : trois pour les participants, Mettre à jour puis Remplacer pour les objectifs, que l’aperçu accepte', () => {
assert.equal(modesDImport('participants'), MODES_D_IMPORT);
assert.deepEqual(modesDImport('objectifs').map(({ mode }) => mode), ['mettreAJour', 'remplacer']);
const charge = chargeDesObjectifs();
for (const { mode, cle } of modesDImport('objectifs')) {
assert.ok(t(cle).length > 0, cle);
assert.equal(apercevoirObjectifs(lignes('table;objectif', '1;finance'), charge, { mode }).mode, mode);
}
assert.throws(() => modesDImport('tables'), RangeError);
});
test('le mode proposé des objectifs : Mettre à jour, que la liste soit vide ou non ; jamais Remplacer', () => {
assert.equal(modePropose([], 'objectifs'), 'mettreAJour');
assert.equal(modePropose([{ id: 1, nom: 'Ombrelle' }], 'objectifs'), 'mettreAJour');
assert.equal(modePropose([], 'participants'), 'ajouter');
});
});
describe('modeleApercu des objectifs de table (§ 10.4)', () => {
test('les colonnes, les quatre champs dans l’ordre de l’export, chacun sa clé ; les lignes : numéro, soirée ou tour, étiquettes une à une, règle nommée', () => {
const charge = chargeDesObjectifs();
const texte = lignes('Règle;Objectif;Tour;Table;Salle', ';finance|Montréal;;1;A', 'toutes;bénévole;2;2;B', 'ou;finance;1;4;C');
const modele = modeleApercu({ nature: 'objectifs', encodage: 'utf-8', apercu: apercevoirObjectifs(texte, charge) });
assert.equal(modele.global, null);
assert.deepEqual(modele.colonnes.map(({ entete, cle }) => [entete, cle]), [
['Règle', 'import.champ.regle'],
['Objectif', 'import.champ.objectif'],
['Tour', 'import.champ.tour'],
['Table', 'import.champ.table'],
['Salle', 'import.colonne.nonReconnue'],
]);
assert.equal(modele.champs, COLONNES_DES_OBJECTIFS);
assert.deepEqual(COLONNES_DES_OBJECTIFS.map(({ champ }) => champ), [...CHAMPS_OBJECTIFS]);
for (const { cle } of COLONNES_DES_OBJECTIFS) assert.ok(t(cle).length > 0, cle);
assert.equal(modele.valides, 3);
assert.deepEqual(modele.lignes, [
{
ligne: 2,
cellules: [
{ champ: 'table', texte: '1', cle: null, etiquettes: null },
{ champ: 'tour', texte: null, cle: 'import.tour.soiree', etiquettes: null },
{ champ: 'objectif', texte: null, cle: null, etiquettes: ['finance', 'Montréal'] },
{ champ: 'regle', texte: null, cle: 'import.regle.defaut', etiquettes: null },
],
},
{
ligne: 3,
cellules: [
{ champ: 'table', texte: '2', cle: null, etiquettes: null },
{ champ: 'tour', texte: '2', cle: null, etiquettes: null },
{ champ: 'objectif', texte: null, cle: null, etiquettes: ['bénévole'] },
{ champ: 'regle', texte: null, cle: 'import.regle.et', etiquettes: null },
],
},
{
ligne: 4,
cellules: [
{ champ: 'table', texte: '4', cle: null, etiquettes: null },
{ champ: 'tour', texte: '1', cle: null, etiquettes: null },
{ champ: 'objectif', texte: null, cle: null, etiquettes: ['finance'] },
{ champ: 'regle', texte: null, cle: 'import.regle.ou', etiquettes: null },
],
},
]);
for (const cle of ['import.tour.soiree', 'import.regle.defaut', 'import.regle.ou', 'import.regle.et']) assert.ok(t(cle).length > 0, cle);
for (const liste of ['fusions', 'doublons', 'homonymes']) assert.deepEqual(modele[liste], [], liste);
assert.equal(modeleApercu({ encodage: 'utf-8', apercu: apercevoir(lignes('nom', 'Ombrelle')) }).champs, COLONNES_DES_LIGNES);
});
test('un refus de chaque motif du § 10.4, dans l’ordre du texte, chacun un motif traduit qui nomme sa cause', () => {
const modele = modeleApercu({ nature: 'objectifs', encodage: 'utf-8', apercu: apercevoirObjectifs(TEXTE_DES_MOTIFS, chargeDesObjectifs()) });
assert.equal(modele.global, null);
assert.deepEqual(modele.refusees.map(({ ligne, code }) => [ligne, code]), MOTIFS_ATTENDUS);
assert.equal(new Set(MOTIFS_ATTENDUS.map(([, code]) => code)).size, 12);
const motif = motifLigne(t);
const textes = modele.refusees.map(({ code, details }) => motif(code, details));
for (const [rang, texte] of textes.entries()) assert.ok(texte.length > 0, MOTIFS_ATTENDUS[rang][1]);
assert.equal(new Set(textes).size, 12);
const parCode = new Map(modele.refusees.map(({ code, details }) => [code, motif(code, details)]));
assert.match(parCode.get('NUMERO_TABLE_INCONNU'), /9/);
assert.match(parCode.get('TOUR_HORS_SOIREE'), /2 tours/);
assert.match(parCode.get('REGLE_INCONNUE'), /peut-être/);
assert.match(parCode.get('OBJECTIF_EN_DOUBLE'), /ligne 2/);
assert.match(parCode.get('ETIQUETTES_TROP_NOMBREUSES'), /21/);
});
test('les étiquettes fondues : un bloc à part, en information, jamais mêlé aux appartenances ; l’orthographe d’une personne de l’événement retenue', () => {
const charge = chargeDesObjectifs({ participants: [porteuse(1, 'Ombrelle', ['Finance'])] });
const modele = modeleApercu({ nature: 'objectifs', encodage: 'utf-8', apercu: apercevoirObjectifs(TEXTE_DES_MOTIFS, charge) });
assert.deepEqual(modele.fusions, []);
assert.deepEqual(modele.fusionsEtiquettes, [{ retenue: 'Finance', orthographes: ['FINANCE', 'Finance', 'finance'], lignes: 2 }]);
assert.deepEqual(modele.lignes.find(({ ligne }) => ligne === 2).cellules[2].etiquettes, ['Finance']);
});
test('participants : appartenances et étiquettes fondues, chacune dans son bloc', () => {
const existants = [{ ...porteuse(4, 'Ombrelle', ['bénévole']), prenom: 'Iris', appartenance: 'Coopérative Bleue' }];
const texte = lignes(
'nom;prenom;appartenance;etiquettes',
'Pervenche;Théo;cooperative bleue;Bénévole|finance',
'Lacasse;Ondine;Grisaille;FINANCE',
);
const modele = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(texte, { participants: existants }) });
assert.deepEqual(modele.fusions.map(({ affichee }) => affichee), ['Coopérative Bleue']);
assert.deepEqual(modele.fusionsEtiquettes, [
{ retenue: 'FINANCE', orthographes: ['FINANCE', 'finance'], lignes: 1 },
{ retenue: 'bénévole', orthographes: ['Bénévole', 'bénévole'], lignes: 1 },
]);
for (const { affichee } of modele.fusions) assert.ok(!modele.fusionsEtiquettes.some(({ retenue }) => retenue === affichee));
const sansFusion = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(lignes('nom;etiquettes', 'Ombrelle;finance')) });
assert.deepEqual(sansFusion.fusionsEtiquettes, []);
});
test('chaque refus global des objectifs : la clé de sa cause, celle de son remède ; un événement sans table mène à la section des tables', () => {
const avecTables = chargeDesObjectifs();
const sansTable = chargeDesObjectifs({ tables: [] });
const cas = [
['CARACTERE_NUL', apercevoirObjectifs('table;objectif\n1;finance\u{0}\n', avecTables), 'import.remede.caractereNulCollage'],
['AUCUN_ENTETE', apercevoirObjectifs(lignes('a;b', '1;finance'), avecTables), 'import.remede.aucunEnteteObjectifs'],
['TABLE_NON_ASSOCIEE', apercevoirObjectifs(lignes('tour;objectif', '1;finance'), avecTables), 'import.remede.tableNonAssociee'],
['OBJECTIF_NON_ASSOCIE', apercevoirObjectifs(lignes('table;tour', '1;1'), avecTables), 'import.remede.objectifNonAssocie'],
['AUCUNE_LIGNE_VALIDE', apercevoirObjectifs(lignes('table;objectif', '9;finance'), avecTables), 'import.remede.aucuneLigneValide'],
['GUILLEMET_OUVERT', apercevoirObjectifs(lignes('table;objectif', '"1;finance'), avecTables), 'import.remede.guillemetOuvert'],
[
'SEPARATEUR_INTROUVABLE',
apercevoirObjectifs(lignes('table;objectif', '1;finance;x', '2'), avecTables),
'import.remede.separateurIntrouvable',
],
];
for (const [code, apercu, remede] of cas) {
assert.equal(apercu.refusGlobal, code, code);
const { global } = modeleApercu({ nature: 'objectifs', encodage: 'utf-8', apercu });
assert.equal(global.cle, code, code);
assert.deepEqual(global.remede, { cle: remede, details: global.details, dansLaCause: false, geste: null }, code);
assert.ok(t(global.remede.cle, global.remede.details).length > 0, code);
assert.ok(t(global.cle, global.details).length > 0, code);
}
const apercu = apercevoirObjectifs(lignes('table;objectif', '1;finance'), sansTable);
assert.equal(apercu.refusGlobal, 'OBJECTIFS_SANS_TABLE');
const { global } = modeleApercu({ nature: 'objectifs', encodage: 'utf-8', apercu });
assert.deepEqual(global, {
cle: 'OBJECTIFS_SANS_TABLE',
details: {},
remede: { cle: null, details: {}, dansLaCause: true, geste: { geste: 'ouvrirSection', section: 'tables' } },
});
assert.ok(t(global.cle).length > 0);
});
test('un collage sous Objectifs : la lecture dans l’ordre de la grille ne s’offre pas, et le remède ne la nomme pas', () => {
const charge = chargeDesObjectifs();
const texte = lignes('1;finance', '2;bénévole');
const apercu = apercevoirObjectifs(texte, charge);
assert.equal(apercu.refusGlobal, 'AUCUN_ENTETE');
assert.equal(avecEntetesDeLaGrille(texte, apercu, 'objectifs'), null);
assert.equal(modeleApercu({ nature: 'objectifs', apercu }).global.remede.cle, 'import.remede.aucunEnteteObjectifs');
const participants = apercevoir(texte);
assert.notEqual(avecEntetesDeLaGrille(texte, participants), null);
});
test('une nature inconnue lève, jamais un modèle deviné', () => {
assert.throws(() => modeleApercu({ nature: 'tables', apercu: apercevoir(lignes('nom', 'Ombrelle')) }), RangeError);
});
});
describe('le rapport et la fenêtre de « Remplacer » selon la nature', () => {
test('les comptes du rapport : ceux du résumé de chaque import, chacun une clé qui s’accorde au nombre', () => {
assert.deepEqual(comptesDuRapport('participants').map(({ nom }) => nom), ['ajoutes', 'misAJour', 'inchanges', 'retires', 'reservationsRetirees']);
assert.deepEqual(comptesDuRapport('objectifs').map(({ nom }) => nom), ['ajoutes', 'remplaces', 'inchanges', 'retires', 'tables']);
for (const nature of ['participants', 'objectifs']) {
for (const { nom, cle } of comptesDuRapport(nature)) {
assert.notEqual(t(cle, { n: 1 }), t(cle, { n: 2 }), `${nature} ${nom}`);
}
}
assert.match(t('import.rapport.objectifsAjoutes', { n: 2 }), /objectifs ajoutés/);
assert.match(t('import.rapport.tablesTouchees', { n: 1 }), /table/);
assert.throws(() => comptesDuRapport('tables'), RangeError);
});
test('remplacer des objectifs annonce les deux comptes de bilanRemplacementObjectifs, accordés', () => {
assert.equal(t('import.remplacer.objectifs', { n: 3 }), '3 objectifs de table seront supprimés');
assert.equal(t('import.remplacer.objectifs', { n: 1 }), '1 objectif de table sera supprimé');
assert.equal(t('import.remplacer.tables', { n: 2 }), '2 tables perdront leurs objectifs');
assert.equal(t('import.remplacer.tables', { n: 1 }), '1 table perdra ses objectifs');
assert.ok(t('import.remplacer.titreObjectifs').length > 0);
});
});

View file

@ -79,6 +79,27 @@ export const TABLE = new Map([
placesManquantes === 1 ? '1 place manque à chaque tour' : `${placesManquantes} places manquent à chaque tour`,
],
// --- Les objectifs hors d'atteinte (§ 5.6, A10) -----------------------------
// Un plancher par valeur que le mode des objectifs compare : le texte dit
// ce qu'aucun placement n'évite.
[
'diagnostic.objectifs.toursHorsObjectif',
({ plancher }) =>
`au moins ${compte(plancher, 'tour hors objectif', 'tours hors objectif')} : ` +
"les tables qui portent un objectif n'ont pas assez de places libres pour chaque tour possible",
],
[
'diagnostic.objectifs.jamaisServies',
({ plancher }) =>
`au moins ${compte(plancher, 'personne jamais servie', 'personnes jamais servies')} par une table à objectif, ` +
'quel que soit le placement',
],
[
'diagnostic.objectifs.horsObjectifMax',
({ plancher }) =>
`au moins une personne passe au moins ${compte(plancher, 'tour', 'tours')} hors objectif, quel que soit le placement`,
],
// --- Le plafond a priori et les imposés (§ 5.4, § 5.5) ---------------------
[
'diagnostic.plafond',
@ -126,5 +147,9 @@ export const TABLE = new Map([
],
['diagnostic.remede.places', "ajouter des tables ou des places, ou exclure des personnes : d'ici là, la génération est refusée"],
['diagnostic.remede.plafond', 'seules les capacités et les réservations le changent : aucune relance ne le dépasse'],
[
'diagnostic.remede.objectifs',
"ajouter des places aux tables qui portent un objectif, ou donner un objectif à d'autres tables, abaisse ce plancher",
],
['diagnostic.remede.imposes', 'les réservations les imposent : ils ne comptent pas parmi les retours que choisit le moteur'],
]);

View file

@ -2,15 +2,21 @@
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les libellés de la génération : meilleure proposition du classement,
// réglages, cœurs de calcul, durée observée, question d'une génération
// longue, avancement et issue (§ 5.4, § 5.7, § 5.10, § 14.6). Chaque
// réglages, mode des objectifs, ordre des priorités, cœurs de calcul, durée
// observée, question d'une génération longue, avancement et issue (§ 5.3,
// § 5.4, § 5.7, § 5.10, § 14.6). Chaque
// clé commence par « generation. ». Le libellé d'un réglage porte son unité,
// entre parenthèses : l'étiquette du champ la dit, et le nom accessible du
// champ avec elle. Un nombre et une durée se mettent en forme par
// creerFormat, sur la composition que la traduction passe à l'entrée ; les
// détails reçus sont des nombres.
// détails reçus sont des nombres, ou le nom d'un critère du registre, que le
// texte écrit sous son nom complet, mobilier.critere.<nom>.
import { creerFormat } from '../../application/format.js';
import { compte } from '../../application/libelles/aides.js';
import { cite, compte, enumeration } from '../../application/libelles/aides.js';
// Le nom complet d'un critère, cité (§ 5.7) : celui que l'application donne
// à mobilier.critere.<nom>, le même que le journal écrit.
const nomCritere = (critere, texte) => cite(texte(`mobilier.critere.${critere}`));
// Les cœurs d'un calcul, dits quand il en emploie plus d'un : « , sur
// 4 cœurs » ; rien pour un seul, ou quand le nombre n'est pas donné.
@ -53,8 +59,84 @@ export const TABLE = new Map([
['generation.contrainte.nouveauxVoisins', 'Nouveaux voisins à chaque tour'],
['generation.contrainte.nouvelleTable', 'Nouvelle table à chaque tour'],
['generation.contrainte.varierAppartenances', 'Varier les appartenances rencontrées'],
['generation.contrainte.respecterObjectifs', 'Respecter les objectifs des tables'],
['generation.contrainte.rencontrerAnimateurs', "Rencontrer le plus d'animateurs différents"],
['generation.contrainte.attribuerSieges', 'Attribuer les sièges'],
// --- Le mode des objectifs, sous sa case (§ 5.3, A8, D20, D22) -----------------
// Le troisième mode garde le libellé de l'utilisateur : sa clé est le seul
// emploi admis de son mot (D22) ; son explication dit ce qu'il compare.
['generation.reglage.modeObjectifs', "Objectifs des tables : chercher d'abord"],
['generation.reglage.modeObjectifs.places', 'le plus de places servies'],
['generation.reglage.modeObjectifs.chacun', 'chacun au moins une fois'],
['generation.reglage.modeObjectifs.minimum', "l'équité d'abord"],
[
'generation.reglage.modeObjectifs.places.explication',
'Le plus grand total de tours servis : le critère compare les tours hors objectif.',
],
[
'generation.reglage.modeObjectifs.chacun.explication',
"D'abord le plus de personnes servies à un tour au moins, puis le plus grand total : le critère compare " +
'les personnes jamais servies, puis les tours hors objectif.',
],
[
'generation.reglage.modeObjectifs.minimum.explication',
"Le minimum d'abord, puis le total : d'abord la personne la plus mal servie, mesurée contre ses propres " +
'tours possibles, puis le plus grand total. Le critère compare le plus de tours hors objectif ' +
"d'une personne, puis les tours hors objectif.",
],
// L'état du critère des tours hors objectif, lu dans la table d'évaluation.
[
'generation.reglage.modeObjectifs.etat.decoche',
"Tours hors objectif : critère décoché. Le mode ne change rien tant que la case n'est pas cochée.",
],
[
'generation.reglage.modeObjectifs.etat.sansObjet',
"Tours hors objectif : critère sans objet. Aucune table à objectif ne peut servir personne : le mode ne change rien.",
],
// --- L'ordre des priorités (§ 5.7, A11, A12) -------------------------------------
['generation.reglage.priorites', 'Ordre des priorités'],
[
'generation.priorites.note',
'Deux propositions se comparent palier par palier : un palier ne départage que des propositions égales sur ' +
'ceux qui le précèdent. Dans un palier de plusieurs critères, chacun compte selon son poids.',
],
['generation.priorites.parDefaut', 'Ordre par défaut : chaque critère seul à son palier.'],
['generation.priorites.regle', "Ordre réglé pour l'événement."],
// palier, compté à partir de 1 ; paliers, leur nombre.
[
'generation.priorites.palier',
({ palier, paliers }, texte) => {
const { nombre } = creerFormat(texte);
return `Palier ${nombre(palier)} sur ${nombre(paliers)}`;
},
],
['generation.priorites.etat.decoche', 'décoché'],
['generation.priorites.etat.sansObjet', 'sans objet'],
['generation.priorites.poids', ({ critere }, texte) => `Poids de ${nomCritere(critere, texte)} (nombre entier)`],
// Les gestes d'un critère, chacun nommant le critère qu'il déplace :
// chaque bouton de l'éditeur porte ainsi un nom à lui (§ 14.5).
['generation.priorites.monter', ({ critere }, texte) => `Monter ${nomCritere(critere, texte)} d'un palier`],
['generation.priorites.monterPalier', ({ critere }, texte) => `Monter le palier de ${nomCritere(critere, texte)}`],
['generation.priorites.descendre', ({ critere }, texte) => `Descendre ${nomCritere(critere, texte)} d'un palier`],
['generation.priorites.descendrePalier', ({ critere }, texte) => `Descendre le palier de ${nomCritere(critere, texte)}`],
['generation.priorites.joindre', ({ critere }, texte) => `Joindre ${nomCritere(critere, texte)} au palier du dessus`],
['generation.priorites.separer', ({ critere }, texte) => `Séparer ${nomCritere(critere, texte)} en palier propre`],
['generation.priorites.retablir', "Rétablir l'ordre par défaut"],
// L'annonce d'un geste abouti : critere, le critère déplacé ; palier,
// compté à partir de 1 ; paliers ; avec, ses compagnons de palier.
[
'generation.priorites.annonce',
({ critere, palier, paliers, avec }, texte) => {
const { nombre } = creerFormat(texte);
const compagnons =
avec.length === 0 ? ', seul' : `, avec ${enumeration(avec.map((autre) => nomCritere(autre, texte)))}`;
return `${nomCritere(critere, texte)} est au palier ${nombre(palier)} sur ${nombre(paliers)}${compagnons}.`;
},
],
['generation.priorites.annonceRetablie', "L'ordre par défaut est rétabli."],
// --- Les cœurs de calcul, un réglage du poste pour la séance (§ 5.10) --------
// disponibles : les processeurs logiques du poste, hyperthreads compris.
['generation.coeurs.titre', 'Cœurs de calcul'],

View file

@ -1,7 +1,8 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les libellés de l'import d'une liste : aperçu et rapport (§ 14.6). Chaque
// Les libellés de l'import d'une liste de participants ou d'objectifs de
// table : nature du fichier, aperçu et rapport (§ 10.4, § 14.6). Chaque
// clé commence par « import. ». Un nombre, un nom ou un rang s'affiche dans
// son propre nœud de texte : les libellés qui le suivent ne reçoivent que ce
// qui accorde le nom au compte.
@ -29,6 +30,11 @@ export const TABLE = new Map([
['import.sorte.avis', 'Avis'],
['import.sorte.information', 'Information'],
// --- La nature du fichier, choisie avant l'aperçu (§ 10.4) --------------------
['import.nature.titre', 'Contenu du fichier'],
['import.nature.participants', 'Participants'],
['import.nature.objectifs', 'Objectifs de table'],
// --- Le mode de l'import (§ 10.1) -------------------------------------------
['import.mode.titre', "Mode de l'import"],
['import.mode.ajouter', 'Ajouter'],
@ -63,6 +69,14 @@ export const TABLE = new Map([
['import.champ.exclu', 'exclu'],
['import.champ.notes', 'notes'],
['import.champ.etiquettes', 'étiquettes'],
['import.champ.table', 'table'],
['import.champ.tour', 'tour'],
['import.champ.objectif', 'objectif'],
['import.champ.regle', 'règle'],
['import.tour.soiree', 'soirée'],
['import.regle.ou', 'au moins une'],
['import.regle.et', 'toutes'],
['import.regle.defaut', 'règle par défaut'],
['import.exclu.oui', 'oui'],
['import.exclu.non', 'non'],
['import.colonne.nonReconnue', 'non reconnue : ignorée'],
@ -78,6 +92,10 @@ export const TABLE = new Map([
['import.apercu.fusions', 'Orthographes fondues en une appartenance'],
['import.apercu.orthographeLignes', ({ n }) => accorde(n, 'ligne', 'lignes')],
['import.apercu.affichee', 'affichée :'],
['import.apercu.fusionsEtiquettes', 'Orthographes fondues en une étiquette'],
['import.apercu.retenue', 'retenue :'],
['import.apercu.orthographes', 'orthographes :'],
['import.apercu.lignesReecrites', ({ n }) => accorde(n, 'ligne réécrite', 'lignes réécrites')],
['import.apercu.doublons', 'Doublons signalés, jamais fusionnés'],
['import.apercu.doublonLignes', 'lignes'],
['import.apercu.doublonExistants', 'déjà dans la liste :'],
@ -92,6 +110,9 @@ export const TABLE = new Map([
"Ajoutez une ligne d'en-tête qui nomme les colonnes, ou lisez les colonnes dans l'ordre de la grille.",
],
['import.remede.nomNonAssocie', 'Nommez « nom » la colonne des noms.'],
['import.remede.aucunEnteteObjectifs', "Ajoutez une ligne d'en-tête qui nomme les colonnes, « table » et « objectif » au moins."],
['import.remede.tableNonAssociee', 'Nommez « table » la colonne des numéros de table.'],
['import.remede.objectifNonAssocie', 'Nommez « objectif » la colonne des étiquettes visées.'],
['import.remede.aucuneLigneValide', 'Corrigez les lignes refusées, listées avec leur motif.'],
[
'import.remede.guillemetOuvert',
@ -128,6 +149,14 @@ export const TABLE = new Map([
({ n }) => `${n} ${accorde(n, 'proposition deviendra périmée', 'propositions deviendront périmées')}`,
],
// --- La fenêtre de « Remplacer » des objectifs (§ 10.4) ----------------------
['import.remplacer.titreObjectifs', 'Remplacer les objectifs des tables'],
[
'import.remplacer.objectifs',
({ n }) => `${n} ${accorde(n, 'objectif de table sera supprimé', 'objectifs de table seront supprimés')}`,
],
['import.remplacer.tables', ({ n }) => `${n} ${accorde(n, 'table perdra ses objectifs', 'tables perdront leurs objectifs')}`],
// --- Le rapport (§ 10.1) ---------------------------------------------------
['import.rapport.titre', "Rapport d'import"],
['import.rapport.ajoutes', ({ n }) => accorde(n, 'personne ajoutée', 'personnes ajoutées')],
@ -136,5 +165,14 @@ export const TABLE = new Map([
['import.rapport.retires', ({ n }) => accorde(n, 'personne retirée', 'personnes retirées')],
['import.rapport.reservationsRetirees', ({ n }) => accorde(n, 'réservation retirée', 'réservations retirées')],
['import.rapport.rienEcrit', "Rien n'a changé dans la liste : seules des lignes refusées restent à lire."],
['import.rapport.objectifsAjoutes', ({ n }) => accorde(n, 'objectif ajouté', 'objectifs ajoutés')],
['import.rapport.objectifsRemplaces', ({ n }) => accorde(n, 'objectif remplacé', 'objectifs remplacés')],
['import.rapport.objectifsInchanges', ({ n }) => accorde(n, 'objectif inchangé', 'objectifs inchangés')],
['import.rapport.objectifsRetires', ({ n }) => accorde(n, 'objectif retiré', 'objectifs retirés')],
['import.rapport.tablesTouchees', ({ n }) => accorde(n, 'table dont les objectifs changent', 'tables dont les objectifs changent')],
[
'import.rapport.rienEcritObjectifs',
"Aucun objectif de table n'a changé : seules des lignes refusées restent à lire.",
],
['import.rapport.designees', 'désignées :'],
]);

View file

@ -7,7 +7,11 @@
// participants.entete.comptes compose la ligne de comptes de la carte
// (comptesEntete, entete.js), ses parties séparées d'un point médian ;
// participants.legende.* nomme la légende des appartenances et chacune de
// ses pastilles, le nom puis le compte.
// ses pastilles, le nom puis le compte ; participants.legendeEtiquettes.titre
// nomme la légende des étiquettes, dont chaque pastille se compose comme
// celle d'une appartenance. participants.etiquettes.* porte la saisie des
// étiquettes sur la fiche : son champ, son ajout, et le retrait nommé par
// l'étiquette qu'il ôte.
//
// participants.colonne.* nomme chaque colonne de COLONNES (grille.js), dans
// l'en-tête de la grille, l'étiquette d'un champ du formulaire et celle
@ -69,6 +73,7 @@ export const TABLE = new Map([
],
['participants.legende.titre', 'Appartenances'],
['participants.legende.element', ({ nom, compte: n }) => `${nom} \u{00B7} ${n}`],
['participants.legendeEtiquettes.titre', 'Étiquettes'],
// --- Le formulaire d'ajout (§ 10.2) ---------------------------------------
['participants.formulaire.titre', 'Ajouter une personne'],
@ -158,6 +163,13 @@ export const TABLE = new Map([
['participants.fiche.horsRetenu', 'hors du placement retenu'],
['participants.fiche.siegeNonAttribue', 'non attribué'],
['participants.fiche.aucunRetenu', 'Aucun placement retenu ne la nomme.'],
['participants.fiche.etiquettes', 'Étiquettes'],
['participants.fiche.aucuneEtiquette', 'Aucune étiquette.'],
// --- Les étiquettes de la fiche (§ 4.5, § 10.2) ---------------------------
['participants.etiquettes.champ', 'Ajouter des étiquettes, séparées par une virgule'],
['participants.etiquettes.ajouter', 'Ajouter'],
['participants.etiquettes.retirer', ({ motCle }) => `Retirer « ${motCle} »`],
// --- Les statuts calculés (§ 4.2) -----------------------------------------
['participants.statut.ancre', 'ancré'],

View file

@ -7,32 +7,49 @@
// Chaque en-tête de colonne nomme son unité (§ 5.4) : collisions cumulées,
// paires distinctes et excédent ne se confondent jamais. Des rencontres, le
// tableau ne porte que le minimum, sur les trois populations du § 5.4 ; la
// moyenne n'y paraît pas. La phrase de l'ordre compose les noms des critères
// par leurs clés moteur.critere.<nom>, dans l'ordre reçu, puis l'identifiant
// croissant, qui départage deux propositions égales sur tous (§ 5.7).
// moyenne n'y paraît pas. La phrase de l'ordre nomme les paliers appliqués,
// chaque valeur qu'un palier applique par sa clé moteur.valeur.<valeur>,
// puis l'identifiant croissant, qui départage deux propositions égales sur
// tous (§ 5.7, A11).
import { compte } from '../../application/libelles/aides.js';
// Le nom d'un critère du classement, celui de la table du moteur.
const critere = (nom, texte) => texte(`moteur.critere.${nom}`);
// Un palier, ses critères {nom, poids} : le nom du critère seul ; pour un
// palier partagé, « ensemble, » puis chaque critère suivi de son poids.
// Les noms d'un critère appliqué, {valeurs} : chaque valeur qu'il classe,
// séparées par « puis ».
const valeursDe = ({ valeurs }, texte) => valeurs.map((valeur) => texte(`moteur.valeur.${valeur}`)).join(texte('propositions.ordre.separateur'));
// Un palier, ses critères {nom, poids, valeurs} : les noms du critère seul ;
// pour un palier partagé, « ensemble, » puis chaque critère suivi de son
// poids. Les mêmes morceaux que la phrase de l'ordre (palierEnMorceaux,
// propositions/modele.js).
const palier = (criteres, texte) =>
criteres.length === 1
? critere(criteres[0].nom, texte)
: `${texte('moteur.palier.ensemble')}, ` +
criteres.map(({ nom, poids }) => `${critere(nom, texte)} (${texte('moteur.palier.poids', { poids })})`).join(', ');
? valeursDe(criteres[0], texte)
: texte('propositions.ordre.ensemble') +
criteres
.map((applique) => `${valeursDe(applique, texte)}${texte('propositions.ordre.poids', { poids: applique.poids })}`)
.join(texte('propositions.ordre.virgule'));
export const TABLE = new Map([
// --- La section (§ 5.7) ------------------------------------------------------
['propositions.tableau.titre', 'Propositions classées'],
['propositions.aucune', "Aucune proposition : la génération en produit, et chaque génération s'ajoute aux précédentes."],
// La phrase des critères se compose de morceaux, chacun sa clé : le début,
// les noms, leur séparateur, l'identifiant croissant et la fin. L'affichage
// les met bout à bout, chaque nom dans son propre élément ;
// propositions.ordre.criteres les assemble en une seule chaîne, la même.
// les noms des valeurs appliquées, le séparateur des paliers, les mots d'un
// palier partagé — « ensemble, », le poids qui suit chaque critère, la
// virgule entre deux critères —, l'identifiant croissant et la fin.
// L'affichage les met bout à bout, chaque nom dans son propre élément.
// propositions.ordre.criteres assemble en une seule chaîne celle de l'ordre
// fixe de référence, sur la liste de ses critères : sous l'ordre par
// défaut, les quatre cases cochées, sans objectif ni case des animateurs,
// la phrase affichée l'égale mot pour mot.
['propositions.ordre.debut', "Ordre du classement, chaque critère au plus faible d'abord : "],
['propositions.ordre.separateur', ', puis '],
['propositions.ordre.ensemble', (_, texte) => `${texte('moteur.palier.ensemble')}, `],
['propositions.ordre.poids', ({ poids }, texte) => ` (${texte('moteur.palier.poids', { poids })})`],
['propositions.ordre.virgule', ', '],
['propositions.ordre.identifiant', "l'identifiant croissant"],
['propositions.ordre.fin', '.'],
[
@ -43,9 +60,9 @@ export const TABLE = new Map([
texte('propositions.ordre.fin'),
],
// Le critère sauté, puis le palier appliqué à sa place : un critère seul
// s'y nomme ; un palier partagé se lit « ensemble, » suivi de chaque
// critère et de son poids. applique null, aucun palier ne prend sa place,
// et la seconde phrase se tait.
// s'y nomme par les valeurs qu'il applique ; un palier partagé se lit
// « ensemble, » suivi de chaque critère et de son poids. applique null,
// aucun palier ne prend sa place, et la seconde phrase se tait.
[
'propositions.ordre.saute',
({ saute, applique }, texte) =>
@ -88,25 +105,36 @@ export const TABLE = new Map([
['propositions.colonne.effectifManque', 'Personnes à qui il en manque'],
['propositions.colonne.ecartItineraireMax', "Écart d'itinéraire maximal (personnes)"],
['propositions.colonne.ecartAuPlafondAPriori', 'Écart au plafond a priori maximal (personnes)'],
['propositions.colonne.jamaisServies', 'Personnes jamais servies par un objectif'],
['propositions.colonne.horsObjectifMax', "Tours hors objectif d'une personne, au plus"],
['propositions.colonne.toursHorsObjectif', 'Tours hors objectif (personne et tour)'],
['propositions.colonne.toursSelonObjectif', "Tours selon l'objectif s(p), minimum"],
['propositions.colonne.animateursRencontres', 'Animateurs rencontrés m(p), minimum'],
['propositions.colonne.animateursNonRencontres', 'Animateurs non rencontrés (personne et animateur)'],
['propositions.colonne.certificat', 'Certificat'],
['propositions.colonne.imposes', 'Imposé par les réservations'],
['propositions.colonne.commandes', 'Commandes'],
// --- Les groupes d'en-têtes, au-dessus des colonnes -------------------------
// Chaque groupe réunit des colonnes voisines ; les en-têtes de colonne
// gardent chacun son unité.
// gardent chacun son unité. Le sixième ne paraît que quand le critère des
// objectifs ou celui des animateurs compte (A11).
['propositions.groupe.rencontres', 'Personnes rencontrées'],
['propositions.groupe.collisions', "Collisions d'appartenance"],
['propositions.groupe.repetitions', 'Rencontres répétées et retours'],
['propositions.groupe.diversite', "Diversité d'appartenance"],
['propositions.groupe.manque', 'Manque et plafond a priori'],
['propositions.groupe.objectifs', 'Objectifs des tables et animateurs'],
// --- Les explications, une par colonne (§ 5.4, § 5.5, § 5.7, § 12.10.4) --------
// Le texte de l'infobulle d'un en-tête, et celui du nom de critère qui
// désigne la colonne dans la phrase de l'ordre : un critère et sa colonne
// partagent la clé. Chacune dit ce que la colonne compte, sur quelle
// population, et dans quel sens elle sert le mieux quand ce sens existe ;
// les termes sont ceux du glossaire du § 5.5.
// Le texte de l'infobulle d'un en-tête, et celui du nom de la valeur
// appliquée qui désigne la colonne dans la phrase de l'ordre : une valeur
// appliquée et sa colonne partagent la clé. Chacune dit ce que la colonne
// compte, sur quelle population, et dans quel sens elle sert le mieux quand
// ce sens existe ; les termes sont ceux du glossaire du § 5.5. Celle d'une
// valeur qui classe ne fixe aucun rang : l'ordre se règle, et sa place se
// lit dans la phrase de l'ordre au-dessus du tableau ; un rang par défaut
// s'y dit comme tel.
[
'propositions.explication.rang',
"La place de la proposition dans le classement, 1 pour la première, selon l'ordre écrit au-dessus du tableau. " +
@ -137,7 +165,8 @@ export const TABLE = new Map([
[
'propositions.explication.collisionsCumulees',
'Chaque fois que deux personnes de la même appartenance partagent une table, compté une fois par paire et par tour : ' +
"deux collègues ensemble aux quatre tours font 4. Critère du classement après l'excédent, il départage deux " +
"deux collègues ensemble aux quatre tours font 4. Critère du classement à la place que lui donne " +
"l'ordre écrit au-dessus du tableau ; l'ordre par défaut le lit après l'excédent, pour départager deux " +
"propositions de même excédent. Plus il est bas, mieux c'est.",
],
[
@ -148,15 +177,16 @@ export const TABLE = new Map([
],
[
'propositions.explication.excedentCollisions',
'Les retrouvailles entre personnes de la même appartenance au-delà de leur première rencontre, soit les collisions ' +
'cumulées moins les paires distinctes : deux collègues ensemble aux quatre tours font 3, six collègues croisés une ' +
'fois chacun font 0. Critère du classement avant les collisions cumulées, car passer la soirée avec le même ' +
"collègue est pire que croiser six collègues une fois. Plus il est bas, mieux c'est.",
'Les retrouvailles entre personnes de même appartenance après leur première rencontre, soit les collisions ' +
'cumulées moins les paires distinctes : deux collègues ensemble aux quatre tours font 3. Critère du classement ' +
"placé par l'ordre écrit au-dessus du tableau, par défaut avant les collisions cumulées : passer la soirée avec " +
"le même collègue est pire que croiser six collègues une fois. Plus il est bas, mieux c'est.",
],
[
'propositions.explication.rencontresRepetees',
"Le nombre de paires de personnes réunies à plus d'un tour que le placement a choisies ; celles qu'imposent les " +
'réservations sont dans la colonne des imposés. Critère du classement après les collisions cumulées. ' +
"réservations sont dans la colonne des imposés. Critère du classement à la place que lui donne l'ordre écrit " +
'au-dessus du tableau. ' +
"Plus il est bas, mieux c'est.",
],
[
@ -166,9 +196,11 @@ export const TABLE = new Map([
],
[
'propositions.explication.retoursChoisis',
"Le nombre de fois où une personne revient à une table où elle s'est déjà assise, additionné sur toutes les personnes. " +
"Seuls comptent les retours que le placement a choisis ; ceux qu'imposent les réservations sont dans la colonne " +
"des imposés. Plus il est bas, mieux c'est.",
"Le nombre de fois où une personne revient à une table où elle s'est déjà assise, additionné sur toutes les personnes ; " +
"seuls comptent les retours que le placement a choisis, ceux qu'imposent les réservations étant dans la colonne " +
"des imposés. Critère du classement quand seule « nouvelle table » est cochée, là où l'ordre écrit au-dessus du " +
"tableau place les rencontres répétées. " +
"Plus il est bas, mieux c'est.",
],
[
'propositions.explication.appartenancesVues',
@ -180,7 +212,8 @@ export const TABLE = new Map([
'propositions.explication.totalRedondance',
"r(p) : le nombre de personnes que p rencontre et qui ont une appartenance, moins A(p), le nombre d'appartenances " +
"différentes qu'elles portent ; à 0, aucune appartenance n'est recroisée. La colonne donne la somme sur toutes les " +
"personnes, Σ r(p), dernier critère du classement avant le numéro. Plus elle est basse, mieux c'est.",
"personnes, Σ r(p), critère du classement à la place que lui donne l'ordre écrit au-dessus du tableau. " +
"Plus elle est basse, mieux c'est.",
],
[
'propositions.explication.diversite',
@ -209,10 +242,56 @@ export const TABLE = new Map([
],
[
'propositions.explication.ecartAuPlafondAPriori',
"L'écart au plafond a priori d'une personne : son plafond a priori, le plus de rencontres que permettent les " +
"Pour chaque personne, son plafond a priori, le plus de rencontres que permettent les " +
"capacités des tables sur le meilleur itinéraire, moins ses rencontres, soit son manque plus son écart d'itinéraire. " +
"La colonne donne le plus grand, premier critère du classement, sauté quand le plafond a priori n'a pas pu être " +
"calculé, la cellule écrivant alors « inconnu ». Plus il est bas, mieux c'est.",
"La colonne donne le plus grand, critère du classement à sa place dans l'ordre écrit au-dessus du tableau ; " +
"sans plafond a priori calculé, il est sauté et la cellule écrit « inconnu ». " +
"Plus il est bas, mieux c'est.",
],
// Les colonnes des objectifs des tables et des animateurs (A9) : une
// personne est concernée par les objectifs quand une table qui lui
// correspond peut l'accueillir à un tour au moins, par les animateurs
// quand elle peut en rencontrer un là où il anime.
[
'propositions.explication.jamaisServies',
"Les personnes concernées par un objectif de table, celles qui ont au moins un tour possible, que le placement " +
"n'assied jamais à une table dont l'objectif leur correspond. Critère du classement sous le mode « chacun au moins " +
"une fois », lu avant les tours hors objectif, au palier que l'ordre écrit au-dessus du tableau " +
"donne aux objectifs. " +
"Plus il est bas, mieux c'est.",
],
[
'propositions.explication.horsObjectifMax',
"Pour chaque personne concernée par un objectif, ses tours possibles moins ses tours selon l'objectif ; la colonne " +
"donne le plus grand, celui de la personne la moins servie. Critère du classement sous le mode qui sert d'abord " +
"la personne la moins servie, lu avant les tours hors objectif, au palier que l'ordre écrit au-dessus du " +
"tableau donne aux objectifs. Plus il est bas, mieux c'est.",
],
[
'propositions.explication.toursHorsObjectif',
"Les couples d'une personne et d'un tour où une table dont l'objectif lui correspond pouvait l'accueillir, mais où " +
"le placement l'assied ailleurs, additionnés sur toutes les personnes. Critère du classement quand des objectifs " +
"de table peuvent servir quelqu'un, à la place que lui donne l'ordre écrit au-dessus du tableau. " +
"Plus il est bas, mieux c'est.",
],
[
'propositions.explication.toursSelonObjectif',
"s(p) : le nombre de tours où p est assis à une table dont l'objectif lui correspond. La colonne donne le plus " +
"petit s(p) parmi les personnes concernées, celles qu'une table à objectif peut accueillir à un tour au moins. " +
"Plus il est grand, mieux c'est.",
],
[
'propositions.explication.animateursRencontres',
"m(p) : le nombre d'animateurs différents que p rencontre à un tour où ils animent, un animateur revu comptant " +
"une fois. La colonne donne le plus petit m(p) parmi les personnes qui peuvent en rencontrer au moins un. " +
"Plus il est grand, mieux c'est.",
],
[
'propositions.explication.animateursNonRencontres',
"Pour chaque personne, le nombre d'animateurs qu'elle pouvait rencontrer là où ils animent, selon les tables et " +
"les réservations, moins ceux qu'elle rencontre, additionné sur toutes les personnes. Critère du classement quand " +
"la case des animateurs est cochée, à la place que lui donne l'ordre écrit au-dessus du tableau. " +
"Plus il est bas, mieux c'est.",
],
[
'propositions.explication.certificat',

View file

@ -11,6 +11,7 @@
// composé par texte depuis son code. Les noms de personnes et les numéros de
// table viennent des détails : une fenêtre à confirmer nomme chaque élément
// détruit dans son propre libellé.
import { JOINTURE_ETIQUETTES } from '../../application/csv.js';
import { cite, compte } from '../../application/libelles/aides.js';
// Un accord au nombre : le singulier jusqu'à un, comme compte.
@ -19,6 +20,14 @@ const accord = (n, singulier, pluriel) => (n <= 1 ? singulier : pluriel);
// La portée d'une réservation en mots : tous les tours, ou le tour désigné.
const portee = ({ portee: genre, tour }) => (genre === 'tous' ? 'pour tous les tours' : `au tour ${tour}`);
// Les étiquettes d'un objectif, citées et jointes comme l'export les
// joint : aucun « et » ni « ou » entre elles, qui dirait une règle que
// l'objectif ne porte pas forcément.
const etiquettesCitees = (etiquettes) => etiquettes.map(cite).join(JOINTURE_ETIQUETTES);
// Ce qu'une règle exige des étiquettes, en mots (§ 4.5).
const exigence = (regle) => (regle === 'et' ? 'toutes' : 'au moins une');
export const TABLE = new Map([
// --- Le défaut (§ 6.1) ------------------------------------------------------
['tables.defaut.etiquette', 'Places par défaut'],
@ -118,6 +127,10 @@ export const TABLE = new Map([
`${reservation.siege === null ? '' : `, siège ${reservation.siege}`}`,
],
['tables.suppression.titre', ({ siege, libelle }) => `Titre ${cite(libelle)}, siège ${siege}`],
[
'tables.suppression.objectif',
({ tour, etiquettes }) => `Objectif ${tour === null ? 'de la soirée' : `du tour ${tour}`} : ${etiquettesCitees(etiquettes)}`,
],
// --- Les tours (§ 2.1 étape 5, § 4.3) ----------------------------------------
['tables.tours.etiquette', 'Nombre de tours'],
@ -129,6 +142,30 @@ export const TABLE = new Map([
'tables.tours.reservation',
({ nom, numero, tour }) => `${nom} : réservation à la table ${numero} au tour ${tour}, détruite`,
],
[
'tables.tours.objectif',
({ numero, tour, etiquettes }) => `Table ${numero} : objectif du tour ${tour} (${etiquettesCitees(etiquettes)}), détruit`,
],
// --- Les objectifs des tables (§ 4.5, § 10.4, D3, D19) ------------------------
// La règle par défaut de l'événement, réglée avec les places et les tours.
['tables.regle.etiquette', 'Règle par défaut des objectifs'],
['tables.regle.ou', 'au moins une (OU)'],
['tables.regle.et', 'toutes (ET)'],
// L'objectif d'une table : son champ d'étiquettes, le choix de sa règle —
// le réglage général, nommé par ce qu'il exige, ou une règle propre —, et
// son état en mots.
['tables.objectif.soiree', 'Objectif de la soirée'],
['tables.objectif.tour', ({ tour }) => `Objectif du tour ${tour}`],
['tables.objectif.regle', "Règle de l'objectif"],
['tables.objectif.regleDefaut', ({ regle }) => `selon le réglage général (${exigence(regle)})`],
['tables.objectif.suitDefaut', 'Suit la règle par défaut'],
['tables.objectif.reglePropre', 'Règle propre'],
['tables.objectif.retirer', "Retirer l'objectif"],
// Le bouton qui déplie les objectifs par tour, et combien de tours en
// portent un.
['tables.objectifs.parTour', ({ nombre }) => (nombre === 0 ? 'Objectifs par tour' : `Objectifs par tour (${nombre})`)],
['tables.objectifs.exporter', 'Exporter les objectifs'],
// Un placement qu'un changement laisse en dérive (§ 9) : il reste, et ne
// correspond plus à la configuration.

View file

@ -2,18 +2,43 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La fiche d'une personne à l'écran (§ 4, § 4.2, § 7.4) : nom, prénom,
// appartenance, courriel, titre pressenti, notes, statut calculé, ses
// réservations, et sa table et son siège à chaque tour du retenu ; un
// tour que le retenu ne porte pas se dit hors du retenu. La fiche
// replie, ne tronque jamais : un nom de deux cents caractères s'y lit
// entier, sur autant de lignes qu'il en faut. Chaque donnée occupe son
// propre nœud de texte, et rien n'y est interprété comme du balisage.
// La fiche d'une personne à l'écran (§ 4, § 4.2, § 4.5, § 7.4, § 10.2) :
// nom, prénom, appartenance, courriel, titre pressenti, notes, statut
// calculé, ses étiquettes, ses réservations, et sa table et son siège à
// chaque tour du retenu ; un tour que le retenu ne porte pas se dit hors
// du retenu. La fiche replie, ne tronque jamais : un nom de deux cents
// caractères s'y lit entier, sur autant de lignes qu'il en faut. Chaque
// donnée occupe son propre nœud de texte, et rien n'y est interprété
// comme du balisage.
//
// Le statut est celui de statuts (src/application/places.js), lu dans
// l'instance du moteur ; l'itinéraire, celui d'itineraire (grille.js), lu
// tour par tour dans etatPlaces : la fiche ne recalcule ni l'un ni
// l'autre, et ne porte aucun contrôle de saisie.
// l'autre. Son seul contrôle de saisie est le champ des étiquettes.
//
// Les étiquettes (etiquettes.js) : une pastille neutre à icône par
// étiquette, dans l'ordre de la charge — la teinte d'appartenance ne sert
// qu'à l'appartenance (§ 12.7) —, chacune suivie de sa commande
// « Retirer », nommée par elle ; puis un formulaire d'ajout, dont le
// champ reçoit une ou plusieurs étiquettes séparées comme dans une
// cellule CSV. Entrée, ou « Ajouter », envoie la saisie entière à
// changerEtiquettes, qui la découpe, la met en forme et la réconcilie : la
// fiche ne lit rien elle-même. Un refus de l'ajout — étiquette mal formée,
// trop d'étiquettes, séance en lecture, plan bloqué — se pose contre le
// champ, avec son remède, la saisie gardée ; une frappe le retire. Un
// ajout abouti vide le champ, sauf ce qui a été tapé pendant l'écriture,
// et pose contre lui le signal de chaque orthographe retenue
// (ORTHOGRAPHE_RETENUE), tant que la révision qui l'a produit est la
// courante et la fiche celle de la personne. Le refus d'un retrait se
// pose à côté de sa commande (Commande). En lecture comme sur un plan
// bloqué, pastilles et commandes restent : la séance refuse le geste, et
// le refus porte son remède. Une saisie non envoyée est un geste ouvert
// (geste.js), qui suspend le compte d'inactivité ; l'envoi n'est pas
// ré-entrant. Passer à une autre personne vide le champ et son refus ; le
// refus d'un envoi qui revient après ce passage n'est posé nulle part.
// Un retrait abouti rend le focus au « Retirer » qui prend la place du
// sien — le suivant, sinon le précédent (ligneVoisine) —, ou au champ
// quand il n'en reste aucun.
//
// La fiche est une carte : fond et filet de la carte, rayon de 12 px,
// ombre légère ; ses étiquettes et légendes de tableau en petites
@ -22,15 +47,24 @@
// Ouverte, ou passée à une autre personne, la fiche se fait défiler
// jusque dans la fenêtre : dans une fenêtre étroite, elle se pose sous la
// grille.
import { getContext } from 'svelte';
import { getContext, onDestroy, tick, untrack } from 'svelte';
import { nomAffiche } from '../../application/personnes.js';
import { etatPlaces, statuts } from '../../application/places.js';
import { cleDeColonne, cleDeStatut, itineraire, reservationsDe } from './grille.js';
import Commande from '../primitives/Commande.svelte';
import Icone from '../primitives/Icone.svelte';
import Pastille from '../primitives/Pastille.svelte';
import Refus from '../primitives/Refus.svelte';
import { creerGeste } from '../primitives/geste.js';
import { ajoutEtiquettes, etiquettesDeLaFiche, retraitEtiquette, saisieApresAjout, signauxOrthographe } from './etiquettes.js';
import { cleDeColonne, cleDeStatut, itineraire, ligneVoisine, reservationsDe } from './grille.js';
let { participant, charge } = $props();
const t = getContext('t');
const ui = getContext('ui');
const seance = getContext('seance');
const uid = $props.id();
const geste = creerGeste(seance);
// Les valeurs de la fiche, sous l'étiquette de leur colonne.
const CHAMPS = ['nom', 'prenom', 'appartenance', 'courriel', 'titrePressenti', 'notes'];
@ -44,10 +78,92 @@
const reservations = $derived(reservationsDe(charge, participant.id));
const etapes = $derived(itineraire(participant.id, charge.evenement.tours, (tour) => etatPlaces(charge, { tour })));
// La fiche et son champ d'ajout, montés : le défilement vise la fiche,
// le focus d'après un retrait ses « Retirer » ou le champ.
let element;
let champ;
$effect(() => {
if (participant.id !== null) element.scrollIntoView({ block: 'nearest' });
});
// La saisie des étiquettes, le refus posé contre son champ, le rendu du
// dernier ajout abouti, et l'envoi en cours.
let texte = $state('');
let refusChamp = $state.raw(null);
let dernier = $state.raw(null);
let envoiEnCours = $state(false);
const idChamp = `${uid}-etiquettes`;
const idRefus = `${uid}-refus-etiquettes`;
const idSignal = `${uid}-orthographe`;
const pastilles = $derived(etiquettesDeLaFiche(participant));
const signaux = $derived(signauxOrthographe(dernier, { revision: $seance.evenement?.revision ?? null, participant: participant.id }));
const decrit = $derived(refusChamp !== null ? idRefus : signaux.length > 0 ? idSignal : undefined);
// Une autre personne : le champ repart vide, sans refus.
let personneSaisie = untrack(() => participant.id);
$effect.pre(() => {
if (participant.id === personneSaisie) return;
personneSaisie = participant.id;
untrack(() => {
texte = '';
refusChamp = null;
});
});
$effect(() => {
geste.tenir(texte !== '');
});
// Le refus d'un geste, ou une erreur sans code, qui remonte.
const refusDe = (erreur) => {
if (typeof erreur?.code !== 'string') throw erreur;
return { code: erreur.code, details: erreur.details ?? {} };
};
async function ajouter(evenement) {
evenement.preventDefault();
if (envoiEnCours) return;
refusChamp = null;
const envoyee = texte;
const id = participant.id;
const { nom, args } = ajoutEtiquettes(id, envoyee);
envoiEnCours = true;
try {
const rendu = await seance.executer(nom, args);
dernier = { revision: seance.etat().evenement.revision, participant: id, avertissements: rendu.avertissements };
texte = saisieApresAjout(envoyee, texte);
} catch (erreur) {
const refus = refusDe(erreur);
if (participant.id === id) refusChamp = refus;
} finally {
envoiEnCours = false;
}
}
// Retire motCle ; aboutie, quand le bouton détruit a laissé le focus au
// document, le porte au « Retirer » voisin ou au champ. Un refus remonte
// à Commande, qui le pose à côté du bouton resté en place.
async function retirer(motCle) {
const id = participant.id;
const rang = pastilles.indexOf(motCle);
const { nom, args } = retraitEtiquette(id, motCle);
await seance.executer(nom, args);
await tick();
if (participant.id !== id) return;
const actuel = document.activeElement;
if (actuel !== null && actuel !== document.body) return;
const voisin = ligneVoisine(rang, pastilles.length);
const boutons = element.querySelectorAll('[data-etiquettes] [data-commande="retirerEtiquette"]');
(voisin === null ? champ : (boutons[voisin] ?? champ))?.focus();
}
// Fait défiler l'élément monté jusque dans la fenêtre, au plus court.
function montrer(cible) {
cible.scrollIntoView({ block: 'nearest' });
}
onDestroy(() => geste.liberer());
</script>
<aside bind:this={element} class="fiche" data-fiche={participant.id} aria-label={t('participants.fiche.titre')}>
@ -67,6 +183,63 @@
<dd>{statut}</dd>
</dl>
<section class="etiquettes" data-etiquettes aria-labelledby={`${uid}-titre-etiquettes`}>
<h4 id={`${uid}-titre-etiquettes`}>{t('participants.fiche.etiquettes')}</h4>
{#if pastilles.length > 0}
<ul>
{#each pastilles as motCle (motCle)}
<li>
<Pastille sorte="statut" valeur="neutre" icone="etiquettes" libelle={motCle} />
<Commande
cle="participants.etiquettes.retirer"
details={{ motCle }}
nom="retirerEtiquette"
variante="discrete"
executer={() => retirer(motCle)}
/>
</li>
{/each}
</ul>
{:else}
<p>{t('participants.fiche.aucuneEtiquette')}</p>
{/if}
<form novalidate onsubmit={ajouter}>
<label for={idChamp}>{t('participants.etiquettes.champ')}</label>
<div class="saisie">
<input
bind:this={champ}
id={idChamp}
type="text"
data-champ="etiquettes"
bind:value={texte}
aria-invalid={refusChamp === null ? undefined : 'true'}
aria-describedby={decrit}
oninput={() => (refusChamp = null)}
/>
<button
type="submit"
data-commande="ajouterEtiquettes"
aria-busy={envoiEnCours ? 'true' : undefined}
aria-disabled={envoiEnCours ? 'true' : undefined}>{t('participants.etiquettes.ajouter')}</button
>
</div>
{#if refusChamp !== null}
<div class="refus" {@attach montrer}>
<Refus id={idRefus} refus={refusChamp} lever={() => (refusChamp = null)} />
</div>
{:else if signaux.length > 0}
<div class="signal" id={idSignal} role="status" data-avertissement="ORTHOGRAPHE_RETENUE" {@attach montrer}>
<span class="signe"><Icone nom="triangle" /></span>
<div>
{#each signaux as signal, rang (rang)}
<p>{t(signal.code, signal.details)}</p>
{/each}
</div>
</div>
{/if}
</form>
</section>
<table>
<caption>{t('participants.fiche.reservations')}</caption>
{#if reservations.length > 0}
@ -181,7 +354,8 @@
}
dt,
caption {
caption,
h4 {
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-intertitre);
font-weight: bold;
@ -224,6 +398,114 @@
color: var(--couleur-page-texte-secondaire);
}
/* Les étiquettes : l'intitulé en petites capitales, les pastilles en
rangée qui se replie, chacune suivie de son « Retirer » ; puis le champ
d'ajout et son bouton sur une ligne, le refus ou le signal dessous. */
.etiquettes {
display: flex;
flex-direction: column;
gap: var(--espace-2);
margin: var(--espace-3) 0;
}
h4 {
margin: 0;
}
ul {
display: flex;
flex-wrap: wrap;
gap: var(--espace-1) var(--espace-3);
margin: 0;
padding: 0;
list-style: none;
}
li {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: var(--espace-1);
min-width: 0;
}
.etiquettes p {
margin: 0;
}
form {
display: flex;
flex-direction: column;
gap: var(--espace-1);
}
label {
font-size: var(--taille-texte-petit);
font-weight: bold;
}
.saisie {
display: flex;
gap: var(--espace-2);
}
input {
box-sizing: border-box;
flex: 1;
min-width: 0;
min-height: 2.5rem;
padding: var(--espace-1) var(--espace-2);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-2);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
font: inherit;
}
input[aria-invalid='true'] {
border-color: var(--couleur-refus);
}
input:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 1px;
}
button[aria-disabled='true'] {
border-style: dashed;
color: var(--couleur-page-texte-secondaire);
cursor: progress;
}
.refus {
min-width: 0;
}
/* L'orthographe retenue : un avis, son signe, un texte de l'encre de la
page. */
.signal {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: var(--espace-2);
padding: var(--espace-2);
border: 1px solid var(--couleur-avis-filet);
border-left: 3px solid var(--couleur-avis-marque);
border-radius: var(--rayon-2);
background-color: var(--couleur-avis-fond);
color: var(--couleur-page-texte);
font-size: var(--taille-texte-petit);
}
.signal .signe {
display: inline-flex;
color: var(--couleur-avis-marque);
}
.signal p {
color: var(--couleur-page-texte);
}
button {
padding: var(--espace-1) var(--espace-3);
border: 1px solid var(--couleur-page-filet);

View file

@ -5,9 +5,10 @@
// § 5.9, § 7.4, § 10.2) : la saisie par le seul nom, l'appartenance
// collante, la grille au clavier, le tri, le filtre et le compte,
// l'exclusion, le manque de places à l'ajout et à la réintégration, la fiche
// et l'itinéraire, l'export, le collage, et la liste groupée par une
// 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
// et l'itinéraire, les étiquettes de la fiche et leur légende, l'export, le
// collage, et la liste groupée par une 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
@ -836,7 +837,8 @@ describe('la fiche (§ 4.2, § 7.4)', () => {
await rendu();
expect(fiche(m).dataset.fiche).toBe(String(id));
expect(valeurDeFiche(m, 'participants.fiche.statut').textContent).toBe(t(cleDeStatut(statut)));
expect(fiche(m).querySelectorAll('input, select, textarea')).toHaveLength(0);
// Le seul contrôle de saisie de la fiche est le champ des étiquettes.
expect([...fiche(m).querySelectorAll('input, select, textarea')].map((champ) => champ.dataset.champ)).toEqual(['etiquettes']);
}
});
@ -927,6 +929,317 @@ describe('la fiche (§ 4.2, § 7.4)', () => {
});
});
describe('les étiquettes : la fiche et la légende (§ 4.5, § 10.2, § 5.9)', () => {
// La rubrique des étiquettes de la fiche, son champ d'ajout, sa commande
// d'ajout, et la légende des étiquettes de la section.
const rubrique = (m) => fiche(m).querySelector('[data-etiquettes]');
const champEtiquettes = (m) => fiche(m).querySelector('[data-champ="etiquettes"]');
const ajoutEtiquettes = (m) => fiche(m).querySelector('[data-commande="ajouterEtiquettes"]');
const retrait = (m, motCle) =>
[...fiche(m).querySelectorAll('[data-commande="retirerEtiquette"]')].find(
(bouton) => bouton.textContent.trim() === t('participants.etiquettes.retirer', { motCle }),
) ?? null;
const legendeEtiquettes = (m) => section(m).querySelector('[data-legende-etiquettes]');
const etiquettesDe = (m, nom) => participants(m).find((p) => p.nom === nom).etiquettes;
// Trois personnes de deux appartenances : les teintes d'appartenance
// existent, et aucune ne doit peindre une étiquette.
const TROIS_ETIQUETEES = [
{ nom: 'Ombrelle', prenom: 'Iris', appartenance: CLUB },
{ nom: 'Pervenche', prenom: 'Théo', appartenance: 'Cercle des Pinsons' },
{ nom: 'Lacasse', prenom: 'Ondine', appartenance: CLUB, exclu: true },
];
// La fiche de la personne nommée, ouverte.
async function ouvrirFiche(m, nom) {
m.ui.ouvrirFiche(idDe(m, nom));
await rendu();
}
// Une saisie dans le champ des étiquettes, envoyée par Entrée.
async function ajouterParClavier(m, texte) {
await saisir(champEtiquettes(m), texte);
champEtiquettes(m).focus();
await touche('Enter');
await attendreSeance(m);
}
test('la fiche montre chaque étiquette en pastille neutre à icône, sa commande « Retirer » nommée par elle ; sans étiquette, elle le dit', async () => {
const m = await evenementOuvert({ personnes: TROIS_ETIQUETEES });
await m.seance.executer('changerEtiquettes', { id: idDe(m, 'Ombrelle'), ajouter: 'finance, Montréal' });
await ouvrirFiche(m, 'Ombrelle');
const elements = [...rubrique(m).querySelectorAll('li')];
expect(elements.map((li) => texteDe(pastilleDans(li)))).toEqual(['Montréal', 'finance']);
const encre = jeton('--couleur-page-texte');
for (const li of elements) {
const pastille = pastilleDans(li);
expect(fondDe(pastille)).toEqual(jeton('--couleur-neutre-fond'));
expect(pastille.querySelector('svg')).not.toBeNull();
expect(composantes(getComputedStyle(pastille).color)).toEqual(encre);
}
// La teinte d'appartenance ne sert qu'à l'appartenance (§ 12.7).
const teintes = new Set([1, 2, 3, 4, 5, 6].map((rang) => JSON.stringify(jeton(`--couleur-appartenance-${rang}-fond`))));
for (const li of elements) expect(teintes.has(JSON.stringify(fondDe(pastilleDans(li))))).toBe(false);
for (const motCle of ['Montréal', 'finance']) expect(retrait(m, motCle), motCle).not.toBeNull();
// En sombre, la pastille prend le neutre sombre.
await sousTheme('sombre', async () => {
for (const li of elements) expect(fondDe(pastilleDans(li))).toEqual(jeton('--couleur-neutre-fond'));
});
await ouvrirFiche(m, 'Pervenche');
expect(rubrique(m).querySelectorAll('li')).toHaveLength(0);
expect(rubrique(m).textContent).toContain(t('participants.fiche.aucuneEtiquette'));
expect(fiche(m).querySelectorAll('[data-commande="retirerEtiquette"]')).toHaveLength(0);
});
test('au clavier : une saisie de trois étiquettes, Entrée, une entrée au journal ; le champ se vide et garde le focus ; « Retirer » ôte la sienne et rend le focus à sa voisine', async () => {
const m = await evenementOuvert({ personnes: TROIS_ETIQUETEES });
await ouvrirFiche(m, 'Pervenche');
const champ = champEtiquettes(m);
expect(document.querySelector(`label[for="${champ.id}"]`).textContent.trim()).toBe(t('participants.etiquettes.champ'));
expect(ajoutEtiquettes(m).textContent.trim()).toBe(t('participants.etiquettes.ajouter'));
const avant = entrees(m).length;
const gestes = vi.spyOn(m.seance, 'debuterGeste');
const finis = vi.spyOn(m.seance, 'finirGeste');
await saisir(champ, 'ressources humaines ; bénévole ; voile');
// Une saisie non envoyée est un geste ouvert.
expect(gestes).toHaveBeenCalled();
expect(m.seance.gesteEnCours).toBe(true);
champ.focus();
await touche('Enter');
await attendreSeance(m);
expect(etiquettesDe(m, 'Pervenche')).toEqual(['bénévole', 'ressources humaines', 'voile']);
expect(entrees(m)).toHaveLength(avant + 1);
expect(champEtiquettes(m).value).toBe('');
// Le champ vidé par l'envoi rend le geste : le compte d'inactivité reprend.
expect(finis).toHaveBeenCalledTimes(1);
expect(m.seance.gesteEnCours).toBe(false);
expect(document.activeElement).toBe(champEtiquettes(m));
expect([...rubrique(m).querySelectorAll('li')].map((li) => texteDe(pastilleDans(li)))).toEqual(['bénévole', 'ressources humaines', 'voile']);
// « Retirer » activé par Entrée : le focus passe au « Retirer » suivant,
// sinon au précédent, sinon au champ ; jamais au document.
const retirerAuClavier = async (motCle) => {
retrait(m, motCle).focus();
await touche('Enter');
await attendreSeance(m);
};
await retirerAuClavier('ressources humaines');
expect(etiquettesDe(m, 'Pervenche')).toEqual(['bénévole', 'voile']);
expect(entrees(m)).toHaveLength(avant + 2);
expect(retrait(m, 'ressources humaines')).toBeNull();
expect(document.activeElement).toBe(retrait(m, 'voile'));
await retirerAuClavier('voile');
expect(etiquettesDe(m, 'Pervenche')).toEqual(['bénévole']);
expect(document.activeElement).toBe(retrait(m, 'bénévole'));
// Le dernier retrait retire le champ de la personne.
await retirerAuClavier('bénévole');
expect(etiquettesDe(m, 'Pervenche')).toBeUndefined();
expect(rubrique(m).textContent).toContain(t('participants.fiche.aucuneEtiquette'));
expect(document.activeElement).toBe(champEtiquettes(m));
// Une saisie non envoyée : fermer la fiche rend le geste qu'elle tient.
await saisir(champEtiquettes(m), 'voile');
expect(m.seance.gesteEnCours).toBe(true);
finis.mockClear();
await cliquer(fiche(m).querySelector('[data-commande="fermerFiche"]'));
expect(fiche(m)).toBeNull();
expect(finis).toHaveBeenCalledTimes(1);
expect(m.seance.gesteEnCours).toBe(false);
});
test('« FINANCE » saisi quand « finance » existe s’enregistre sous « finance », et le signal d’orthographe se pose contre le champ', async () => {
const m = await evenementOuvert({ personnes: TROIS_ETIQUETEES });
await m.seance.executer('changerEtiquettes', { id: idDe(m, 'Ombrelle'), ajouter: 'finance' });
await ouvrirFiche(m, 'Pervenche');
await ajouterParClavier(m, 'FINANCE');
expect(etiquettesDe(m, 'Pervenche')).toEqual(['finance']);
const signal = fiche(m).querySelector('[data-avertissement="ORTHOGRAPHE_RETENUE"]');
expect(signal).not.toBeNull();
expect(visible(signal)).toBe(true);
expect(texteDe(signal)).toBe(t('ORTHOGRAPHE_RETENUE', { saisie: 'FINANCE', retenue: 'finance' }));
expect(champEtiquettes(m).getAttribute('aria-describedby')).toBe(signal.id);
// Contre le champ : le signal suit le champ dans la fiche, sans rien entre eux.
expect(ecart(signal, champEtiquettes(m))).toBeLessThan(48);
// Une autre fiche ne le montre pas ; revenue à la personne, sous la même
// révision, la fiche le montre de nouveau.
await ouvrirFiche(m, 'Ombrelle');
expect(fiche(m).querySelector('[data-avertissement]')).toBeNull();
expect(champEtiquettes(m).hasAttribute('aria-describedby')).toBe(false);
await ouvrirFiche(m, 'Pervenche');
expect(fiche(m).querySelector('[data-avertissement="ORTHOGRAPHE_RETENUE"]')).not.toBeNull();
// Un geste suivant le retire.
await ajouterParClavier(m, 'bénévole');
expect(fiche(m).querySelector('[data-avertissement="ORTHOGRAPHE_RETENUE"]')).toBeNull();
expect(champEtiquettes(m).hasAttribute('aria-describedby')).toBe(false);
});
test('refusé contre le champ : une tabulation, 41 caractères, des séparateurs seuls, une vingt et unième ; la saisie reste, rien ne s’écrit', async () => {
const m = await evenementOuvert({ personnes: TROIS_ETIQUETEES });
const vingt = Array.from({ length: 20 }, (_, i) => `mot${String(i).padStart(2, '0')}`).join(', ');
await m.seance.executer('changerEtiquettes', { id: idDe(m, 'Ombrelle'), ajouter: vingt });
await ouvrirFiche(m, 'Pervenche');
const avant = texteJournal(m);
const cas = [
['voile, a\u{0009}b', 'ETIQUETTE_INVALIDE'],
['x'.repeat(41), 'ETIQUETTE_INVALIDE'],
[' , ; | ', 'ETIQUETTE_INVALIDE'],
];
for (const [saisie, code] of cas) {
await ajouterParClavier(m, saisie);
const champ = champEtiquettes(m);
expect(champ.getAttribute('aria-invalid'), saisie).toBe('true');
const message = document.getElementById(champ.getAttribute('aria-describedby'));
expect(message.dataset.refus, saisie).toBe(code);
expect(visible(message)).toBe(true);
expect(champ.value, saisie).toBe(saisie);
expect(fiche(m).contains(message)).toBe(true);
}
expect(texteJournal(m)).toBe(avant);
// Une frappe retire le refus.
await saisir(champEtiquettes(m), 'voile');
expect(champEtiquettes(m).hasAttribute('aria-invalid')).toBe(false);
await ouvrirFiche(m, 'Ombrelle');
await ajouterParClavier(m, 'voile');
const champ = champEtiquettes(m);
const message = document.getElementById(champ.getAttribute('aria-describedby'));
expect(message.dataset.refus).toBe('ETIQUETTES_EN_TROP');
expect(texteDe(message)).toContain(t('ETIQUETTES_EN_TROP', { nombre: 21 }));
expect(etiquettesDe(m, 'Ombrelle')).toHaveLength(20);
expect(texteJournal(m)).toBe(avant);
// Passer à une autre personne vide le champ et son refus.
await ouvrirFiche(m, 'Pervenche');
expect(champEtiquettes(m).value).toBe('');
expect(champEtiquettes(m).hasAttribute('aria-invalid')).toBe(false);
expect(champEtiquettes(m).hasAttribute('aria-describedby')).toBe(false);
expect(fiche(m).querySelector('[data-refus]')).toBeNull();
});
test('un refus rendu après le passage à une autre personne ne se pose pas sur son champ', async () => {
const m = await evenementOuvert({ personnes: TROIS_ETIQUETEES });
// Une écriture retenue tient la file de la séance : l'ajout tapé
// ensuite attend derrière elle.
const liberer = retenirJournal(m);
const tenue = m.seance.executer('changerEtiquettes', { id: idDe(m, 'Lacasse'), ajouter: 'finance' });
await ouvrirFiche(m, 'Pervenche');
await saisir(champEtiquettes(m), 'a\u{0009}b');
champEtiquettes(m).focus();
await touche('Enter');
await rendu();
await ouvrirFiche(m, 'Ombrelle');
liberer();
await tenue;
await attendreSeance(m);
expect(champEtiquettes(m).value).toBe('');
expect(champEtiquettes(m).hasAttribute('aria-invalid')).toBe(false);
expect(champEtiquettes(m).hasAttribute('aria-describedby')).toBe(false);
expect(fiche(m).querySelector('[data-refus]')).toBeNull();
});
test('une seconde Entrée pendant l’écriture n’envoie rien : un seul envoi, une entrée au journal, aucun refus', async () => {
const m = await evenementOuvert({ personnes: TROIS_ETIQUETEES });
await ouvrirFiche(m, 'Pervenche');
const avant = entrees(m).length;
const executer = vi.spyOn(m.seance, 'executer');
const liberer = retenirJournal(m);
await saisir(champEtiquettes(m), 'bénévole');
champEtiquettes(m).focus();
await touche('Enter');
await rendu();
expect(ajoutEtiquettes(m).getAttribute('aria-busy')).toBe('true');
await touche('Enter');
await rendu();
liberer();
await vi.waitFor(() => expect(etiquettesDe(m, 'Pervenche')).toEqual(['bénévole']));
await attendreSeance(m);
expect(executer.mock.calls.filter(([nom]) => nom === 'changerEtiquettes')).toHaveLength(1);
expect(entrees(m)).toHaveLength(avant + 1);
expect(fiche(m).querySelector('[data-refus]')).toBeNull();
expect(champEtiquettes(m).hasAttribute('aria-invalid')).toBe(false);
});
test('la légende des étiquettes suit celle des appartenances, chacune chiffrée exclues comprises, en pastilles neutres ; absente sans étiquette', async () => {
const m = await evenementOuvert({ personnes: TROIS_ETIQUETEES });
expect(legendeEtiquettes(m)).toBeNull();
expect(legende(m)).not.toBeNull();
await m.seance.executer('changerEtiquettes', { id: idDe(m, 'Ombrelle'), ajouter: 'finance, Montréal' });
await m.seance.executer('changerEtiquettes', { id: idDe(m, 'Lacasse'), ajouter: 'finance' });
await attendreSeance(m);
const porteur = legendeEtiquettes(m);
expect(porteur).not.toBeNull();
expect(porteur.getAttribute('aria-label')).toBe(t('participants.legendeEtiquettes.titre'));
// Sous celle des appartenances : sa voisine immédiate, dans le même
// porteur des légendes.
expect(legende(m).nextElementSibling).toBe(porteur);
expect(porteur.parentElement).toBe(legende(m).parentElement);
const lue = () => [...porteur.querySelectorAll('li')].map((li) => ({ texte: texteDe(li), fond: fondDe(pastilleDans(li)) }));
const neutre = jeton('--couleur-neutre-fond');
expect(lue()).toEqual([
{ texte: t('participants.legende.element', { nom: 'finance', compte: 2 }), fond: neutre },
{ texte: t('participants.legende.element', { nom: 'Montréal', compte: 1 }), fond: neutre },
]);
for (const li of porteur.querySelectorAll('li')) expect(pastilleDans(li).querySelector('svg')).not.toBeNull();
// Un filtre ne la change pas.
await saisir(section(m).querySelector('[data-champ="filtre"]'), 'Pervenche');
expect(idsAffiches(m)).toHaveLength(1);
expect(lue()).toHaveLength(2);
// La dernière étiquette retirée, la légende part.
await m.seance.executer('changerEtiquettes', { id: idDe(m, 'Ombrelle'), retirer: 'finance' });
await m.seance.executer('changerEtiquettes', { id: idDe(m, 'Ombrelle'), retirer: 'Montréal' });
await m.seance.executer('changerEtiquettes', { id: idDe(m, 'Lacasse'), retirer: 'finance' });
await attendreSeance(m);
expect(legendeEtiquettes(m)).toBeNull();
});
test('en lecture : les pastilles restent ; « Ajouter » et « Retirer » sont refusés, chacun avec son remède, et rien ne s’écrit', async () => {
const m = await evenementOuvert({ personnes: TROIS_ETIQUETEES });
await m.seance.executer('changerEtiquettes', { id: idDe(m, 'Ombrelle'), ajouter: 'finance' });
await m.seance.passerEnLecture();
await ouvrirFiche(m, 'Ombrelle');
const avant = texteJournal(m);
expect(texteDe(pastilleDans(rubrique(m).querySelector('li')))).toBe('finance');
await ajouterParClavier(m, 'bénévole');
const message = document.getElementById(champEtiquettes(m).getAttribute('aria-describedby'));
expect(message.dataset.refus).toBe('MODE_LECTURE');
expect(message.querySelector('[data-commande]')).not.toBeNull();
await cliquer(retrait(m, 'finance'));
await attendreSeance(m);
const refusRetrait = retrait(m, 'finance').parentElement.querySelector('[data-refus]');
expect(refusRetrait.dataset.refus).toBe('MODE_LECTURE');
expect(refusRetrait.querySelector('[data-commande]')).not.toBeNull();
expect(etiquettesDe(m, 'Ombrelle')).toEqual(['finance']);
expect(texteJournal(m)).toBe(avant);
});
test('plan bloqué, en écriture : « Ajouter » et « Retirer » sont refusés avec « Débloquer » pour remède', async () => {
const m = await evenementOuvert({ sieges: 4, tours: 2, tables: 1, personnes: TROIS_ETIQUETEES });
await m.seance.executer('changerEtiquettes', { id: idDe(m, 'Ombrelle'), ajouter: 'finance' });
await m.app.generation().generer({ graine: 1, nombre: 1, arret: 50, historique: 1000 });
const [proposition] = m.seance.etat().charge.propositions;
await m.seance.executer('retenirProposition', { id: proposition.id });
await m.seance.executer('changerEtat', { etat: 'bloque' });
await ouvrirFiche(m, 'Ombrelle');
const avant = texteJournal(m);
await ajouterParClavier(m, 'bénévole');
const message = document.getElementById(champEtiquettes(m).getAttribute('aria-describedby'));
expect(message.querySelector('[data-commande="debloquer"]')).not.toBeNull();
await cliquer(retrait(m, 'finance'));
await attendreSeance(m);
const refusRetrait = retrait(m, 'finance').parentElement.querySelector('[data-refus]');
expect(refusRetrait.querySelector('[data-commande="debloquer"]')).not.toBeNull();
expect(texteJournal(m)).toBe(avant);
});
});
describe('export et collage (§ 10.2)', () => {
test('« Exporter la liste » remet les octets d’exporterParticipants ; réimportés dans un événement neuf, ils rendent les mêmes participants, exclu compris', async () => {
const m = await evenementOuvert();

View file

@ -5,8 +5,8 @@
// La section des participants (§ 2.1 étape 3, § 4, § 10.2) : une carte
// titrée, dont l'en-tête porte la ligne de comptes (comptesEntete,
// entete.js) et l'export ; dessous, le formulaire d'ajout en bande de tête,
// puis la barre du filtre, du groupement, du compte et de la légende des
// appartenances, puis la grille ; à côté, la fiche ouverte, ou dessous
// puis la barre du filtre, du groupement, du compte et des légendes des
// appartenances et des étiquettes, puis la grille ; à côté, la fiche ouverte, ou dessous
// quand la ligne ne tient pas les deux. Les lignes de la grille sont
// celles de lignesGrille (grille.js) sur la liste de la séance, le tri et le filtre de la
// section : regarder, trier et filtrer n'écrivent rien, en écriture comme
@ -39,7 +39,12 @@
// Les teintes d'appartenance (teintesAppartenances) se calculent une fois
// sur la liste entière, exclues comprises, et la légende comme la grille
// les lisent : un filtre ne repeint rien. La légende nomme chaque
// appartenance avec le nombre de personnes qui la portent.
// appartenance avec le nombre de personnes qui la portent. Dessous, quand
// l'événement porte des étiquettes, la légende des étiquettes
// (legendeEtiquettes, entete.js) nomme chacune avec le nombre de
// personnes qui la portent, exclues comprises, en pastilles neutres à
// icône : la teinte d'appartenance ne sert qu'à l'appartenance (§ 12.7).
// Sans étiquette, elle n'est pas montrée.
//
// 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
@ -60,7 +65,7 @@
import { statuts } from '../../application/places.js';
import { conflitsDuPlacement } from '../../application/conflits_placement.js';
import { conflitsDesGroupes, lignesDeConflit, reglesParPersonne } from './conflits.js';
import { comptesEntete, legendeAppartenances } from './entete.js';
import { comptesEntete, legendeAppartenances, legendeEtiquettes } from './entete.js';
import {
choisirGroupement,
cleDeGroupement,
@ -93,6 +98,7 @@
const teintes = $derived(teintesAppartenances(participants));
const comptes = $derived(comptesEntete(participants));
const legende = $derived(legendeAppartenances(participants, teintes));
const legendeDesEtiquettes = $derived(legendeEtiquettes(participants));
const lignes = $derived(lignesGrille(participants, { tri, filtre }, cleNormalisee));
const toutes = $derived(lignesGrille(participants, { tri, filtre: '' }, cleNormalisee));
const parStatut = $derived(groupement.includes('statut') && charge !== null ? statuts(charge).parId : null);
@ -179,12 +185,25 @@
</button>
{/if}
<output>{t('participants.grille.compte', { total: participants.length, affiches: lignes.length })}</output>
{#if legende.length > 0}
<ul class="legende" aria-label={t('participants.legende.titre')}>
{#each legende as { nom, rang, compte } (nom)}
<li><Pastille sorte="appartenance" valeur={rang} libelle={t('participants.legende.element', { nom, compte })} /></li>
{/each}
</ul>
{#if legende.length > 0 || legendeDesEtiquettes.length > 0}
<div class="legendes">
{#if legende.length > 0}
<ul class="legende" aria-label={t('participants.legende.titre')}>
{#each legende as { nom, rang, compte } (nom)}
<li><Pastille sorte="appartenance" valeur={rang} libelle={t('participants.legende.element', { nom, compte })} /></li>
{/each}
</ul>
{/if}
{#if legendeDesEtiquettes.length > 0}
<ul class="legende" data-legende-etiquettes aria-label={t('participants.legendeEtiquettes.titre')}>
{#each legendeDesEtiquettes as { nom, compte } (nom)}
<li>
<Pastille sorte="statut" valeur="neutre" icone="etiquettes" libelle={t('participants.legende.element', { nom, compte })} />
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
@ -280,13 +299,25 @@
font-size: var(--taille-texte-petit);
}
/* Les légendes, poussées à droite tant que la ligne les tient : celle
des étiquettes sous celle des appartenances. */
.legendes {
display: flex;
flex-direction: column;
align-self: center;
align-items: flex-end;
gap: var(--espace-2);
min-width: 0;
margin-left: auto;
}
.legende {
display: flex;
flex-wrap: wrap;
align-self: center;
justify-content: flex-end;
gap: var(--espace-1) var(--espace-2);
min-width: 0;
margin: 0 0 0 auto;
margin: 0;
padding: 0;
list-style: none;
}

View file

@ -2,10 +2,14 @@
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// L'en-tête de la liste des participants, pur : la ligne de comptes de la
// carte et la légende des appartenances. Les deux se lisent sur la liste
// entière, exclues comprises — la population de l'attribution des teintes
// (teintesAppartenances, appartenances.js) — : un filtre ne change ni un
// compte ni une teinte. Aucune fonction ne modifie ce qu'elle reçoit.
// carte, la légende des appartenances et celle des étiquettes. Toutes se
// lisent sur la liste entière, exclues comprises — la population de
// l'attribution des teintes (teintesAppartenances, appartenances.js) — : un
// filtre ne change ni un compte ni une teinte. Aucune fonction ne modifie ce
// qu'elle reçoit.
import { cleNormalisee } from '../../application/csv.js';
import { comparerPointsDeCode } from '../../application/planche.js';
/**
* La ligne de comptes de la carte des participants.
@ -49,3 +53,27 @@ export function legendeAppartenances(participants, teintes) {
}
return legende;
}
/**
* La légende des étiquettes (§ 10.2) : une entrée par étiquette portée,
* avec le nombre de personnes qui la portent, exclues comprises. Deux
* orthographes sont deux étiquettes, comme pour le moteur (§ 4.5) ; elles
* se rangent comme les appartenances : par clé normalisée (§ 10.1),
* comparée point de code par point de code, puis par leur chaîne. L'ordre
* ne dépend donc pas de celui de la liste. Vide quand personne ne porte
* d'étiquette : la section ne montre alors aucune légende.
*
* @param {Array<{etiquettes?: string[]}>} participants tous ceux de
* l'événement
* @returns {Array<{nom: string, compte: number}>}
*/
export function legendeEtiquettes(participants) {
const comptes = new Map();
for (const { etiquettes = [] } of participants) {
for (const motCle of new Set(etiquettes)) comptes.set(motCle, (comptes.get(motCle) ?? 0) + 1);
}
return [...comptes]
.map(([nom, compte]) => ({ nom, compte, cle: cleNormalisee(nom) }))
.sort((a, b) => comparerPointsDeCode(a.cle, b.cle) || comparerPointsDeCode(a.nom, b.nom))
.map(({ nom, compte }) => ({ nom, compte }));
}

View file

@ -1,17 +1,17 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// L'en-tête de la liste des participants : la ligne de comptes de la carte
// et la légende des appartenances, chacune lue sur la liste entière, exclues
// comprises, et le texte que les libellés en composent. Les noms d'épreuve
// sont inventés.
// L'en-tête de la liste des participants : la ligne de comptes de la carte,
// la légende des appartenances et celle des étiquettes, chacune lue sur la
// liste entière, exclues comprises, et le texte que les libellés en
// composent. Les noms d'épreuve sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { RANGS_APPARTENANCE, teintesAppartenances } from '../appartenances.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { comptesEntete, legendeAppartenances } from './entete.js';
import { comptesEntete, legendeAppartenances, legendeEtiquettes } from './entete.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
@ -105,3 +105,50 @@ describe('legendeAppartenances : la légende en pastilles, avec leur compte', ()
assert.equal(t('participants.legende.titre'), 'Appartenances');
});
});
// Une personne qui porte des étiquettes ; sans liste, le champ est absent,
// comme dans la charge.
const etiquetee = (id, etiquettes, exclu = false) => {
const p = personne(id, null, exclu);
return etiquettes === undefined ? p : { ...p, etiquettes };
};
describe('legendeEtiquettes : la légende des étiquettes, avec leur compte (§ 10.2)', () => {
test('chaque étiquette portée, par sa clé normalisée puis par points de code, avec le nombre de personnes qui la portent, exclues comprises', () => {
const liste = [
etiquetee(1, ['Montréal', 'finance']),
etiquetee(2, ['finance']),
etiquetee(3, ['bénévole', 'finance'], true),
etiquetee(4, undefined),
etiquetee(5, ['Finance']),
];
assert.deepEqual(legendeEtiquettes(liste), [
{ nom: 'bénévole', compte: 1 },
{ nom: 'Finance', compte: 1 },
{ nom: 'finance', compte: 3 },
{ nom: 'Montréal', compte: 1 },
]);
});
test('sans étiquette portée, une légende vide : la section ne la montre pas', () => {
assert.deepEqual(legendeEtiquettes([]), []);
assert.deepEqual(legendeEtiquettes([etiquetee(1, undefined), etiquetee(2, undefined, true)]), []);
});
test('une étiquette égale au nom d’une appartenance reste une étiquette, et l’appartenance ne s’y compte pas', () => {
const liste = [{ ...etiquetee(1, ['Atelier Pivoine']), appartenance: 'Atelier Pivoine' }, personne(2, 'Atelier Pivoine')];
assert.deepEqual(legendeEtiquettes(liste), [{ nom: 'Atelier Pivoine', compte: 1 }]);
assert.deepEqual(legendeAppartenances(liste, teintesAppartenances(liste)), [{ nom: 'Atelier Pivoine', rang: 1, compte: 2 }]);
});
test('l’ordre ne dépend pas de l’ordre de la liste : un filtre ou un tri ne le change pas', () => {
const liste = [etiquetee(1, ['ressources humaines']), etiquetee(2, ['bénévole', 'Montréal']), etiquetee(3, ['finance'])];
assert.deepEqual(legendeEtiquettes([...liste].reverse()), legendeEtiquettes(liste));
});
test('le titre de la légende des étiquettes ; chaque élément, le nom puis le compte, comme les appartenances', () => {
assert.equal(t('participants.legendeEtiquettes.titre'), 'Étiquettes');
assert.notEqual(t('participants.legendeEtiquettes.titre'), t('participants.legende.titre'));
assert.equal(t('participants.legende.element', { nom: 'finance', compte: 3 }), 'finance \u{00B7} 3');
});
});

View file

@ -0,0 +1,72 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le modèle des étiquettes de la fiche d'une personne (§ 4.5, § 10.2,
// § 5.9), pur : Fiche.svelte le câble. Les pastilles sont les étiquettes de
// la personne dans l'ordre de la charge ; l'ajout et le retrait passent par
// la seule commande changerEtiquettes, la saisie entière telle que tapée :
// la découpe, la forme, la réconciliation des orthographes et les refus
// sont ceux de la commande (src/application/objectifs.js), jamais refaits
// ici. Le signal d'orthographe est l'avertissement ORTHOGRAPHE_RETENUE que
// l'ajout rend, gardé contre le champ tant que la révision qui l'a produit
// est la courante et la fiche celle de la personne. Aucune fonction ne
// modifie ce qu'elle reçoit.
// La commande des étiquettes d'une personne.
const COMMANDE = 'changerEtiquettes';
// Le code de l'avertissement que la fiche pose contre son champ.
const ORTHOGRAPHE = 'ORTHOGRAPHE_RETENUE';
/**
* Les pastilles de la fiche : les étiquettes de la personne, dans l'ordre
* de la charge, en copie ; aucune quand le champ est absent.
*
* @param {{etiquettes?: string[]}} participant
* @returns {string[]}
*/
export const etiquettesDeLaFiche = (participant) => [...(participant.etiquettes ?? [])];
/**
* L'ajout d'une saisie aux étiquettes de la personne id : la commande et
* ses arguments, la saisie telle que tapée.
*
* @param {number} id
* @param {string} texte
* @returns {{nom: string, args: {id: number, ajouter: string}}}
*/
export const ajoutEtiquettes = (id, texte) => ({ nom: COMMANDE, args: { id, ajouter: texte } });
/**
* Le retrait d'une étiquette de la personne id, nommée par sa chaîne exacte.
*
* @param {number} id
* @param {string} motCle
* @returns {{nom: string, args: {id: number, retirer: string}}}
*/
export const retraitEtiquette = (id, motCle) => ({ nom: COMMANDE, args: { id, retirer: motCle } });
/**
* Ce que le champ garde après un ajout abouti : vide quand il porte encore
* la saisie envoyée, sinon ce qui a été tapé pendant l'écriture.
*
* @param {string} envoyee
* @param {string} courante
* @returns {string}
*/
export const saisieApresAjout = (envoyee, courante) => (courante === envoyee ? '' : courante);
/**
* Les signaux d'orthographe que la fiche pose contre son champ : les
* avertissements ORTHOGRAPHE_RETENUE du dernier ajout abouti, dans leur
* ordre, quand sa révision est la courante et sa personne celle de la
* fiche ; aucun sinon.
*
* @param {{revision: number, participant: number, avertissements: Array<{code: string, details: Object}>}|null} dernier
* @param {{revision: number, participant: number}} courant
* @returns {Array<{code: string, details: {saisie: string, retenue: string}}>}
*/
export function signauxOrthographe(dernier, { revision, participant }) {
if (dernier === null || dernier.revision !== revision || dernier.participant !== participant) return [];
return dernier.avertissements.filter(({ code }) => code === ORTHOGRAPHE);
}

View file

@ -0,0 +1,108 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le modèle des étiquettes de la fiche (§ 4.5, § 10.2, § 5.9) : les
// pastilles, la commande que portent l'ajout et le retrait, la saisie qui
// reste après un ajout, et le signal d'orthographe gardé contre le champ.
// Les libellés de la fiche se composent ici. Les noms d'épreuve sont
// inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { appliquerCommande } from '../../application/commandes.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { creerCharge } from '../../stockage/document.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { ajoutEtiquettes, etiquettesDeLaFiche, retraitEtiquette, saisieApresAjout, signauxOrthographe } from './etiquettes.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
// Une charge de deux personnes, la première étiquetée « finance ».
function chargeEssai() {
let charge = creerCharge({ id: 'evt-grives', nom: 'Soirée des Grives', siegesParDefaut: 4, tours: 2 });
for (const nom of ['Ombrelle', 'Pervenche']) charge = appliquerCommande('ajouterParticipant', charge, { champs: { nom } }).charge;
return appliquerCommande('changerEtiquettes', charge, { id: 1, ajouter: 'finance' }).charge;
}
describe('etiquettesDeLaFiche : les pastilles de la fiche', () => {
test('les étiquettes de la personne, dans l’ordre de la charge, en copie', () => {
const participant = { id: 1, etiquettes: ['Montréal', 'finance'] };
const pastilles = etiquettesDeLaFiche(participant);
assert.deepEqual(pastilles, ['Montréal', 'finance']);
pastilles.push('bénévole');
assert.deepEqual(participant.etiquettes, ['Montréal', 'finance']);
});
test('sans le champ, aucune pastille', () => {
assert.deepEqual(etiquettesDeLaFiche({ id: 2 }), []);
});
});
describe('ajoutEtiquettes, retraitEtiquette : une seule commande, changerEtiquettes', () => {
test('la saisie part entière, telle que tapée : la découpe et la forme sont celles de la commande', () => {
assert.deepEqual(ajoutEtiquettes(3, ' finance ; Montréal|bénévole '), {
nom: 'changerEtiquettes',
args: { id: 3, ajouter: ' finance ; Montréal|bénévole ' },
});
});
test('le retrait nomme l’étiquette par sa chaîne exacte', () => {
assert.deepEqual(retraitEtiquette(3, 'Montréal'), { nom: 'changerEtiquettes', args: { id: 3, retirer: 'Montréal' } });
});
test('sur une charge, l’ajout pose les étiquettes reçues, le retrait les ôte', () => {
const avant = chargeEssai();
const { nom, args } = ajoutEtiquettes(2, 'FINANCE, ressources humaines');
const rendu = appliquerCommande(nom, avant, args);
assert.deepEqual(rendu.charge.participants[1].etiquettes, ['finance', 'ressources humaines']);
const retrait = retraitEtiquette(2, 'finance');
assert.deepEqual(appliquerCommande(retrait.nom, rendu.charge, retrait.args).charge.participants[1].etiquettes, ['ressources humaines']);
});
});
describe('saisieApresAjout : ce que le champ garde après un ajout abouti', () => {
test('la saisie envoyée, encore là, se vide ; une frappe pendant l’écriture reste', () => {
assert.equal(saisieApresAjout('finance', 'finance'), '');
assert.equal(saisieApresAjout('finance', 'finance, Mont'), 'finance, Mont');
assert.equal(saisieApresAjout('finance', ''), '');
});
});
describe('signauxOrthographe : le signal d’orthographe contre le champ (§ 4.5)', () => {
const avertissements = [
{ code: 'ORTHOGRAPHE_RETENUE', details: { saisie: 'FINANCE', retenue: 'finance' } },
{ code: 'PLACES_MANQUANTES', details: { manque: 1 } },
{ code: 'ORTHOGRAPHE_RETENUE', details: { saisie: 'Bénévole', retenue: 'bénévole' } },
];
const dernier = { revision: 7, participant: 3, avertissements };
test('les orthographes retenues du dernier ajout, dans leur ordre, tant que sa révision est la courante et la fiche la même', () => {
assert.deepEqual(signauxOrthographe(dernier, { revision: 7, participant: 3 }), [
{ code: 'ORTHOGRAPHE_RETENUE', details: { saisie: 'FINANCE', retenue: 'finance' } },
{ code: 'ORTHOGRAPHE_RETENUE', details: { saisie: 'Bénévole', retenue: 'bénévole' } },
]);
});
test('une autre révision, une autre personne, ou aucun ajout : aucun signal', () => {
assert.deepEqual(signauxOrthographe(dernier, { revision: 8, participant: 3 }), []);
assert.deepEqual(signauxOrthographe(dernier, { revision: 7, participant: 4 }), []);
assert.deepEqual(signauxOrthographe(null, { revision: 7, participant: 3 }), []);
});
test('sur une charge : « FINANCE » saisi quand « finance » existe rend le signal, que le libellé dit', () => {
const rendu = appliquerCommande('changerEtiquettes', chargeEssai(), { id: 2, ajouter: 'FINANCE' });
const [signal] = signauxOrthographe({ revision: 1, participant: 2, avertissements: rendu.avertissements }, { revision: 1, participant: 2 });
assert.deepEqual(signal, { code: 'ORTHOGRAPHE_RETENUE', details: { saisie: 'FINANCE', retenue: 'finance' } });
assert.match(t(signal.code, signal.details), /FINANCE.*finance/);
});
});
describe('les libellés de la fiche', () => {
test('la rubrique, le champ, l’ajout, le retrait nommé par l’étiquette, et la fiche sans étiquette', () => {
assert.equal(t('participants.fiche.etiquettes'), 'Étiquettes');
assert.equal(t('participants.fiche.aucuneEtiquette'), 'Aucune étiquette.');
assert.equal(t('participants.etiquettes.champ'), 'Ajouter des étiquettes, séparées par une virgule');
assert.equal(t('participants.etiquettes.ajouter'), 'Ajouter');
assert.equal(t('participants.etiquettes.retirer', { motCle: 'Montréal' }), 'Retirer « Montréal »');
});
});

View file

@ -42,6 +42,8 @@ const DESSINS = [
['chevron', ['M9 6l6 6-6 6']],
['points', [cercle(5, 12, 1.5), cercle(12, 12, 1.5), cercle(19, 12, 1.5)]],
['demarrer', ['M8 5l11 7-11 7z']],
// Une étiquette (§ 4.5) : l'étiquette à œillet, pointe en bas à droite.
['etiquettes', ['M3 3h8l10 10-8 8L3 11z', cercle(7.5, 7.5, 1.5)]],
];
/** Les noms des icônes, dans l'ordre du module. */

View file

@ -17,7 +17,7 @@ describe('icônes des primitives', () => {
assert.deepEqual(NOMS_ICONES, [
'crayon', 'listePoints', 'coche', 'cadenas', 'liste', 'importer', 'exporter', 'ajouter', 'chercher',
'calendrier', 'information', 'triangle', 'interdit', 'dossier', 'oeil', 'stylo', 'defaire', 'imprimer',
'etoile', 'croix', 'chevron', 'points', 'demarrer',
'etoile', 'croix', 'chevron', 'points', 'demarrer', 'etiquettes',
]);
assert.ok(Object.isFrozen(ICONES) && Object.isFrozen(NOMS_ICONES));
assert.deepEqual(Object.keys(ICONES).sort(), [...NOMS_ICONES].sort());

View file

@ -18,6 +18,7 @@
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { flushSync } from 'svelte';
import { appliquerCommande } from '../../application/commandes.js';
import { configurationDe } from '../../application/configuration.js';
import { creerFormat } from '../../application/format.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { nomAffiche } from '../../application/personnes.js';
@ -40,7 +41,7 @@ import { fixerCadre, pointer, touche } from '../../../test/navigateur/entrees.js
import { monterApp } from '../../../test/navigateur/montage.js';
import { composantes, contraste, couleurTexte, fondPeintSous, sousTheme } from '../../../test/navigateur/rendu.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { GROUPES_COLONNES, evaluation, tableau } from './modele.js';
import { evaluation, tableau } from './modele.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const format = creerFormat(t);
@ -1095,7 +1096,10 @@ describe('l’habillage du tableau des propositions', () => {
const thead = section(m).querySelector('thead');
expect(thead.rows.length).toBe(2);
const groupes = [...thead.rows[0].querySelectorAll('th')];
const attendus = GROUPES_COLONNES.filter(({ cle }) => cle !== null);
// Sans objectif ni case des animateurs, les cinq groupes du tableau.
const segments = tableau(evaluation(charge(m)).table).groupes;
const attendus = segments.filter(({ cle }) => cle !== null);
expect(attendus.length).toBe(5);
expect(groupes.map(texteDe)).toEqual(attendus.map(({ cle }) => t(cle)));
expect(thead.rows[0].querySelectorAll('[data-colonne]').length).toBe(0);
expect([...thead.rows[1].querySelectorAll('th[data-colonne]')].map((th) => th.dataset.colonne)).toEqual(NOMS_DES_COLONNES);
@ -1110,7 +1114,7 @@ describe('l’habillage du tableau des propositions', () => {
expect(g.bottom).toBeLessThanOrEqual(premiere.top + 1);
}
const colgroups = [...section(m).querySelectorAll('table > colgroup')];
expect(colgroups.map((colgroup) => colgroup.span)).toEqual(GROUPES_COLONNES.map(({ etendue }) => etendue));
expect(colgroups.map((colgroup) => colgroup.span)).toEqual(segments.map(({ etendue }) => etendue));
});
test('les nombres, en-têtes compris, s’alignent à droite en chiffres tabulaires', async () => {
@ -1171,6 +1175,184 @@ describe('l’habillage du tableau des propositions', () => {
});
});
// --- Le sixième groupe et la phrase par paliers (§ 5.7, A9, A11) ------------------
// La petite démonstration au conflit inévitable et les trois plans de
// PLANS_CONFLIT, les personnes 1 à 6 « finance », la première table attendant
// « finance » pour la soirée ; les réglages donnés s'ajoutent à ceux de la
// charge.
function petiteAObjectifs(reglages = {}) {
const chargeLue = avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT);
for (const participant of chargeLue.participants) if (participant.id <= 6) participant.etiquettes = ['finance'];
const [premiere] = [...chargeLue.tables].sort((a, b) => a.id - b.id);
premiere.objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }];
Object.assign(chargeLue.reglages, reglages);
return chargeLue;
}
describe('le sixième groupe et la phrase par paliers (§ 5.7, A9, A11)', () => {
// Le pointeur réel part du coin du cadre, hors de la phrase et du tableau.
beforeEach(async () => {
await pointer('bouger', 0, 0);
});
const enTetesDesColonnes = (m) => [...section(m).querySelector('thead').rows[1].querySelectorAll('th[data-colonne]')].map((th) => th.dataset.colonne);
const enTeteDuGroupe = (m) =>
[...section(m).querySelector('thead').rows[0].querySelectorAll('th')].find((th) => texteDe(th) === t('propositions.groupe.objectifs')) ?? null;
// Les colonnes du défaut, celles du groupe des objectifs et des animateurs
// insérées après l'écart au plafond a priori, avant le certificat.
const avecSixieme = (...noms) => [...NOMS_DES_COLONNES.slice(0, 18), ...noms, ...NOMS_DES_COLONNES.slice(18)];
test('« chacun au moins une fois » : le groupe « Objectifs des tables et animateurs » couvre ses trois colonnes, chiffres du moteur ; la phrase nomme « personnes jamais servies, puis tours hors objectif »', async () => {
const chargeLue = executer(petiteAObjectifs({ modeObjectifs: 'chacun' }), 'retenirProposition', { id: 3 });
const m = await ouvrir(chargeLue);
expect(enTetesDesColonnes(m)).toEqual(avecSixieme('jamaisServies', 'toursHorsObjectif', 'toursSelonObjectif'));
const groupe = enTeteDuGroupe(m);
expect(groupe).not.toBeNull();
expect(groupe.getAttribute('scope')).toBe('colgroup');
expect(groupe.colSpan).toBe(3);
const g = groupe.getBoundingClientRect();
const thead = section(m).querySelector('thead');
expect(Math.abs(g.left - thead.querySelector('th[data-colonne="jamaisServies"]').getBoundingClientRect().left)).toBeLessThanOrEqual(1);
expect(Math.abs(g.right - thead.querySelector('th[data-colonne="toursSelonObjectif"]').getBoundingClientRect().right)).toBeLessThanOrEqual(1);
const instance = normaliser(configurationDe(chargeLue));
for (const proposition of chargeLue.propositions) {
const { objectifs } = mesurer(instance, indexerPlan(instance, planDepuisFichier(proposition)));
const lues = cellules(ligne(m, proposition.id));
expect(lues.get('jamaisServies')).toBe(format.nombre(objectifs.jamaisServies));
expect(lues.get('toursHorsObjectif')).toBe(format.nombre(objectifs.horsObjectif));
expect(lues.get('toursSelonObjectif')).toBe(format.nombre(objectifs.agregat.tous.min));
}
expect(cellules(ligneRetenu(m)).has('toursHorsObjectif')).toBe(true);
expect(section(m).querySelector('[data-ordre] p').textContent).toBe(
"Ordre du classement, chaque critère au plus faible d'abord : écart au plafond a priori, puis excédent de collisions, " +
'puis collisions cumulées, puis personnes jamais servies, puis tours hors objectif, puis rencontres répétées, ' +
"puis redondance d'appartenance, puis l'identifiant croissant.",
);
});
test('les deux noms du critère des objectifs, au clavier : chacun surligne sa colonne seule et ouvre son explication, qu’il est seul à désigner', async () => {
const m = await ouvrir(executer(petiteAObjectifs({ modeObjectifs: 'chacun' }), 'retenirProposition', { id: 3 }));
const noms = [...section(m).querySelectorAll('[data-ordre] [data-critere="toursHorsObjectif"]')];
expect(noms.map(texteDe)).toEqual(['personnes jamais servies', 'tours hors objectif']);
for (const [nom, nomColonne] of [
[noms[0], 'jamaisServies'],
[noms[1], 'toursHorsObjectif'],
]) {
expect(nom.tabIndex).toBe(0);
nom.focus();
expect(colonne(m, nomColonne).length).toBe(5);
await attendre(() => expect(surlignees(m), nomColonne).toEqual(colonne(m, nomColonne)));
await attendre(() => exigerInfobulle(nom, explicationDeColonne(nomColonne)));
nom.blur();
await attendre(() => expect(surlignees(m)).toEqual([]));
}
});
test('« nouvelle table » cochée seule : le nom « retours » surligne la colonne des retours choisis, jamais celle des rencontres répétées', async () => {
const chargeLue = avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT);
for (const cle of ['separerAppartenances', 'nouveauxVoisins', 'varierAppartenances']) chargeLue.reglages[cle] = false;
const m = await ouvrir(chargeLue);
expect(texteDe(section(m).querySelector('[data-ordre]'))).toBe(
"Ordre du classement, chaque critère au plus faible d'abord : écart au plafond a priori, puis retours, puis l'identifiant croissant.",
);
const nom = section(m).querySelector('[data-ordre] [data-critere="rencontresRepetees"]');
expect(texteDe(nom)).toBe('retours');
const { x, y } = await centre(nom);
await pointer('bouger', x, y);
await attendre(() => expect(surlignees(m)).toEqual(colonne(m, 'retoursChoisis')));
for (const cellule of colonne(m, 'rencontresRepetees')) expect(cellule.dataset.marque).toBeUndefined();
await attendre(() => exigerInfobulle(nom, explicationDeColonne('retoursChoisis')));
});
test('un palier partagé : « ensemble, », chaque nom suivi de son poids ; seuls les noms se focalisent, les poids restent du texte', async () => {
const chargeLue = avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT);
const e = (critere, poids = 1) => ({ critere, poids });
chargeLue.reglages.priorites = [
[e('ecartAuPlafondAPrioriMax')],
[e('excedentCollisions', 3), e('redondance', 2)],
[e('collisionsCumulees')],
[e('toursHorsObjectif')],
[e('rencontresRepetees')],
[e('animateursNonRencontres')],
];
const m = await ouvrir(chargeLue);
expect(section(m).querySelector('[data-ordre] p').textContent).toBe(
"Ordre du classement, chaque critère au plus faible d'abord : écart au plafond a priori, puis ensemble, " +
"excédent de collisions (poids 3), redondance d'appartenance (poids 2), puis collisions cumulées, " +
"puis rencontres répétées, puis l'identifiant croissant.",
);
const noms = nomsDesignants(m);
expect(noms.map((nom) => nom.dataset.critere)).toEqual([
'ecartAuPlafondAPrioriMax',
'excedentCollisions',
'redondance',
'collisionsCumulees',
'rencontresRepetees',
'identifiant',
]);
for (const nom of noms) expect(texteDe(nom)).not.toMatch(/poids|ensemble/);
const redondance = section(m).querySelector('[data-ordre] [data-critere="redondance"]');
redondance.focus();
await attendre(() => expect(surlignees(m)).toEqual(colonne(m, 'totalRedondance')));
});
test('chaque en-tête du sixième groupe se focalise, ouvre son explication, aligne ses nombres à droite en chiffres tabulaires ; ses textes à 4,5:1 dans les deux thèmes', async () => {
const m = await ouvrir(petiteAObjectifs({ modeObjectifs: 'minimum' }));
const noms = ['horsObjectifMax', 'toursHorsObjectif', 'toursSelonObjectif'];
expect(enTetesDesColonnes(m)).toEqual(avecSixieme(...noms));
for (const nom of noms) {
const th = enTetes(m).find((candidat) => candidat.dataset.colonne === nom);
expect(th.tabIndex).toBe(0);
th.focus();
await attendre(() => exigerInfobulle(th, explicationDeColonne(nom)));
for (const cellule of colonne(m, nom)) {
const style = getComputedStyle(cellule);
expect(style.textAlign, nom).toBe('right');
expect(style.fontVariantNumeric, nom).toContain('tabular-nums');
}
th.blur();
}
await attendre(() => exigerFermee());
for (const theme of ['clair', 'sombre']) {
await sousTheme(theme, async () => {
flushSync();
for (const element of [enTeteDuGroupe(m), ...noms.flatMap((nom) => colonne(m, nom))]) {
element.scrollIntoView({ block: 'center', inline: 'center' });
const [fond] = fondPeintSous(element);
expect(contraste(couleurTexte(element), fond), `${theme} ${element.dataset.colonne ?? 'groupe'}`).toBeGreaterThanOrEqual(4.5);
}
});
}
});
test('la case des animateurs cochée sur la grande : le groupe couvre le minimum des animateurs rencontrés et les animateurs non rencontrés, chiffres du moteur', async () => {
const depart = structuredClone(chargeDemo('grande'));
depart.reglages.rencontrerAnimateurs = true;
const chargeLue = generee(depart, { graine: 1, nombre: 1, arret: 2000 });
const m = await ouvrir(chargeLue);
expect(enTetesDesColonnes(m)).toEqual(avecSixieme('animateursRencontres', 'animateursNonRencontres'));
expect(enTeteDuGroupe(m).colSpan).toBe(2);
const instance = normaliser(configurationDe(chargeLue));
const { animateurs } = mesurer(instance, indexerPlan(instance, planDepuisFichier(chargeLue.propositions[0])));
const lues = cellules(ligne(m, 1));
expect(lues.get('animateursRencontres')).toBe(format.nombre(animateurs.agregat.tous.min));
expect(lues.get('animateursNonRencontres')).toBe(format.nombre(animateurs.nonRencontres));
const nom = section(m).querySelector('[data-ordre] [data-critere="animateursNonRencontres"]');
expect(texteDe(nom)).toBe('animateurs non rencontrés');
nom.focus();
await attendre(() => expect(surlignees(m)).toEqual(colonne(m, 'animateursNonRencontres')));
});
test('le critère des objectifs décoché : ni groupe ni colonne des objectifs, et la phrase ne le nomme pas', async () => {
const m = await ouvrir(petiteAObjectifs({ respecterObjectifs: false, modeObjectifs: 'chacun' }));
expect(enTetesDesColonnes(m)).toEqual(NOMS_DES_COLONNES);
expect(enTeteDuGroupe(m)).toBeNull();
expect(section(m).querySelector('[data-ordre] [data-critere="toursHorsObjectif"]')).toBeNull();
expect(texteDe(section(m).querySelector('[data-ordre]'))).toBe(t('propositions.ordre.criteres', { criteres: [...CRITERES] }));
});
});
// --- Les pages du classement (§ 5.7, § 2.1 étapes 9 et 10, § 8.4) ---------------
// La grande démonstration et 237 propositions (graine 7, arrêt 50) : brute,

View file

@ -9,9 +9,9 @@
// « Retenir » et « Effacer », le refus posé à côté du bouton.
//
// La table d'évaluation se lit dans la charge de l'état observable, par
// evaluation (modele.js) : une seule mesure, celle d'evaluer (evaluation.js), refaite
// à chaque charge écrite ; les indicateurs du retenu suivent donc
// l'ajustement (§ 2.1 étape 11).
// evaluation (modele.js) : une seule mesure, celle d'evaluer
// (evaluation.js), refaite à chaque charge écrite ; les indicateurs du
// retenu suivent donc l'ajustement (§ 2.1 étape 11).
//
// « Voir sur le plan » demande la proposition au plan par l'état de
// navigation, sans écriture, et ouvre la section du plan ; voir(null) y
@ -24,20 +24,23 @@
// aucune saisie — chaque proposition se régénère de sa graine, et le geste
// se défait — : il s'exécute sans fenêtre.
//
// La phrase de l'ordre se met bout à bout, morceau par morceau (modele.js) ;
// chaque nom de critère, et l'identifiant croissant, est un élément
// focalisable qui désigne sa colonne. Le survoler ou le focaliser surligne
// cette colonne dans le tableau. Le survol et le focus se tiennent à part,
// et surlignee (modele.js) choisit : le nom survolé, sinon celui qui a le
// focus. Quitter un nom — pointeur sorti, focus perdu — n'efface que l'état
// qu'il tenait, et laisse l'autre.
// La phrase de l'ordre se met bout à bout, morceau par morceau (modele.js) :
// les paliers appliqués, chaque nom celui de la valeur que son palier
// applique — deux noms pour un critère qui en classe deux. Chaque nom, et
// l'identifiant croissant, est un élément focalisable qui porte le critère
// qui le classe (data-critere) et désigne la colonne de sa valeur. Le
// survoler ou le focaliser surligne cette colonne dans le tableau. Le
// survol et le focus se tiennent à part, et surlignee (modele.js) choisit :
// le nom survolé, sinon celui qui a le focus. Quitter un nom — pointeur
// sorti, focus perdu — n'efface que l'état qu'il tenait, et laisse l'autre.
//
// Chaque nom de critère et chaque en-tête de colonne s'expliquent dans
// l'infobulle de la section, une seule (Infobulle, primitive) : le survol
// ou le focus l'ouvre sur le terme, le quitter la referme quand elle est
// encore la sienne, Échap aussi. Un critère et sa colonne partagent leur
// explication (explicationDeColonne, modele.js). Le terme expliqué porte
// aria-describedby vers elle, et lui seul.
// Chaque nom de la phrase, celui d'une valeur appliquée, et chaque en-tête
// de colonne s'expliquent dans l'infobulle de la section, une seule
// (Infobulle, primitive) : le survol ou le focus l'ouvre sur le terme, le
// quitter la referme quand elle est encore la sienne, Échap aussi. Une
// valeur appliquée et sa colonne partagent leur explication
// (explicationDeColonne, colonneDeValeur, modele.js). Le terme expliqué
// porte aria-describedby vers elle, et lui seul.
//
// Au-delà de TAILLE_PAGE propositions (modele.js), le tableau se lit page
// à page : pagine découpe le classement unique, sans le retrier, et la
@ -163,10 +166,10 @@
const surlignee = $derived(colonneSurlignee(survolee, focalisee));
// Le terme expliqué : {ancre, terme, cle, details} — son élément, sa
// désignation, 'critere:<critère>' pour un nom de la phrase ou
// 'colonne:<nom>' pour un en-tête, et la clé de son explication — ; null
// sans infobulle. Ouvrir sur un terme remplace l'explication d'un autre ;
// quitter un terme ne ferme que la sienne.
// désignation, 'valeur:<valeur>' pour un nom de la phrase, où chaque
// valeur paraît une fois, ou 'colonne:<nom>' pour un en-tête, et la clé
// de son explication — ; null sans infobulle. Ouvrir sur un terme remplace
// l'explication d'un autre ; quitter un terme ne ferme que la sienne.
const uid = $props.id();
const idExplication = `${uid}-explication`;
let expliquee = $state(null);
@ -181,15 +184,15 @@
const decrit = (terme) => (expliquee?.terme === terme ? idExplication : undefined);
// L'entrée sur un nom, par le pointeur ou par le focus, et sa sortie.
function entrer(evenement, { critere, colonne }, parFocus) {
function entrer(evenement, { valeur, colonne }, parFocus) {
if (parFocus) focalisee = colonne;
else survolee = colonne;
expliquer(evenement, `critere:${critere}`, colonne);
expliquer(evenement, `valeur:${valeur}`, colonne);
}
function sortir({ critere }, parFocus) {
function sortir({ valeur }, parFocus) {
if (parFocus) focalisee = null;
else survolee = null;
taire(`critere:${critere}`);
taire(`valeur:${valeur}`);
}
const effacer = () => seance.executer('effacerPropositions', {});
@ -254,7 +257,7 @@
class:actif={surlignee === morceau.colonne}
data-critere={morceau.critere}
tabindex="0"
aria-describedby={decrit(`critere:${morceau.critere}`)}
aria-describedby={decrit(`valeur:${morceau.valeur}`)}
onmouseenter={(evenement) => entrer(evenement, morceau, false)}
onmouseleave={() => sortir(morceau, false)}
onfocus={(evenement) => entrer(evenement, morceau, true)}

View file

@ -11,8 +11,10 @@
// Les violations d'une ligne comparable — retenu compris — se lisent sous
// son identifiant.
//
// vue : ce que rend tableau(), libellés et étendues compris, ses lignes
// et ses lignes hors classement réduites à celles de la page montrée
// vue : ce que rend tableau(), libellés et étendues compris — ses colonnes
// et ses groupes, ceux que porte la table, le sixième groupe selon les
// critères qui comptent —, ses lignes et ses lignes hors classement
// réduites à celles de la page montrée
// (pagine, modele.js) ; chaque ligne garde son rang global. nommer(id) :
// le nom affiché d'une personne, null pour id null ou pour une personne que
// la charge n'a plus. voir(id) et
@ -26,11 +28,12 @@
//
// Chaque en-tête explique sa colonne dans l'infobulle de la section : il se
// focalise au clavier (tabindex 0), et son survol ou son focus appelle
// expliquer(evenement, 'colonne:<nom>', nom), le quitter taire('colonne:<nom>').
// expliquer(evenement, 'colonne:<nom>', nom), le quitter
// taire('colonne:<nom>').
// decrit('colonne:<nom>') rend l'identifiant de l'infobulle quand elle
// explique cet en-tête, pour son aria-describedby, undefined sinon.
//
// Deux rangées d'en-têtes : celle des groupes (GROUPES_COLONNES, modele.js),
// Deux rangées d'en-têtes : celle des groupes (vue.groupes, modele.js),
// chacun en scope colgroup au-dessus de ses colonnes, une cellule vide au-
// dessus d'une colonne hors groupe ; puis celle des colonnes, seule à
// porter data-colonne et les explications. Les colonnes de nombres
@ -50,7 +53,7 @@
import Commande from '../primitives/Commande.svelte';
import Icone from '../primitives/Icone.svelte';
import Pastille from '../primitives/Pastille.svelte';
import { GROUPES_COLONNES, texteCellule } from './modele.js';
import { texteCellule } from './modele.js';
let { vue, surlignee = null, nommer, voir, retenir, expliquer, taire, decrit } = $props();
@ -123,12 +126,12 @@
<div class="defilement">
<table aria-label={t('propositions.tableau.titre')}>
{#each GROUPES_COLONNES as segment, rang (rang)}
{#each vue.groupes as segment, rang (rang)}
<colgroup span={segment.etendue}></colgroup>
{/each}
<thead>
<tr class="groupes">
{#each GROUPES_COLONNES as segment, rang (rang)}
{#each vue.groupes as segment, rang (rang)}
{#if segment.cle === null}
<td></td>
{:else}

View file

@ -5,14 +5,14 @@
// § 12.6, § 12.9), pur : SectionPropositions.svelte et
// TableauPropositions.svelte le câblent.
//
// Le tableau lit la table d'évaluation d'evaluer (evaluation.js), la seule mesure : chaque
// cellule porte la valeur que la table rend, telle quelle, et sa clé de mise
// en forme ; aucune arithmétique ne s'y fait, aucun plafond ne s'y recompose.
// Des rencontres, il ne porte que le minimum, sur les trois populations du
// § 5.4 — tous, mobiles, ancrés —, en tête des colonnes de mesure ; la moyenne
// n'y entre pas. Le plafond réalisé, individuel, se lit par le manque, dont
// les cellules sont celles de troisChiffres. Les imposés se disent à part, sur
// chaque ligne, jamais ajoutés aux choisis.
// Le tableau lit la table d'évaluation d'evaluer (evaluation.js), la seule
// mesure : chaque cellule porte la valeur que la table rend, telle quelle, et
// sa clé de mise en forme ; aucune arithmétique ne s'y fait, aucun plafond
// ne s'y recompose. Des rencontres, il ne porte que le minimum, sur les trois
// populations du § 5.4 — tous, mobiles, ancrés —, en tête des colonnes de
// mesure ; la moyenne n'y entre pas. Le plafond réalisé, individuel, se lit
// par le manque, dont les cellules sont celles de troisChiffres. Les imposés
// se disent à part, sur chaque ligne, jamais ajoutés aux choisis.
//
// Chaque minimum affiché désigne la personne la plus mal servie (§ 12.1) :
// le moins de rencontres, d'appartenances vues, de diversité. La redondance
@ -20,6 +20,12 @@
// son minimum désignerait la personne la mieux servie : elle se lit par sa
// somme Σ r(p), la grandeur que la recherche et le classement lisent.
//
// Les objectifs des tables et les animateurs (A9, A11) forment un sixième
// groupe de colonnes, présentes chacune quand son critère compte — coché et
// avec un objet, tel que l'état des critères de la table le dit — : le
// tableau ne recalcule ni « sans objet » ni le mode, qu'il lit dans les
// valeurs que le critère des objectifs classe.
//
// Une cellule vaut { colonne, valeur, cle, decimales } : cle null, la valeur
// est un nombre que format.nombre écrit avec decimales chiffres après la
// virgule ; sinon le texte est t(cle) — « — » pour une population vide,
@ -51,9 +57,30 @@ const LECTURES = new Map([
['effectifManque', (l) => l.troisChiffres.effectifManque.tous],
['ecartItineraireMax', (l) => l.troisChiffres.ecartItineraireMax?.tous ?? null],
['ecartAuPlafondAPriori', (l) => l.ecartAuPlafondAPrioriMax],
['jamaisServies', (l) => l.mesures.objectifs?.jamaisServies ?? null],
['horsObjectifMax', (l) => l.mesures.objectifs?.horsObjectifMax ?? null],
['toursHorsObjectif', (l) => l.mesures.objectifs?.horsObjectif ?? null],
['toursSelonObjectif', (l) => l.mesures.objectifs?.agregat.tous.min ?? null],
['animateursRencontres', (l) => l.mesures.animateurs?.agregat.tous.min ?? null],
['animateursNonRencontres', (l) => l.mesures.animateurs?.nonRencontres ?? null],
['certificat', (l) => l.minimumAtteint],
]);
// Les colonnes que le tableau ne porte que sous une condition : [critère,
// valeur], la colonne présente quand le critère compte et, valeur non
// null, quand il classe cette valeur — les personnes jamais servies sous
// « chacun au moins une fois », le plus de tours hors objectif d'une
// personne sous le troisième mode. Toute autre colonne est toujours là. La
// Map ne sert qu'à retrouver la condition d'une colonne.
const SOUS_CONDITION = new Map([
['jamaisServies', ['toursHorsObjectif', 'jamaisServies']],
['horsObjectifMax', ['toursHorsObjectif', 'horsObjectifMax']],
['toursHorsObjectif', ['toursHorsObjectif', 'toursHorsObjectif']],
['toursSelonObjectif', ['toursHorsObjectif', null]],
['animateursRencontres', ['animateursNonRencontres', null]],
['animateursNonRencontres', ['animateursNonRencontres', 'animateursNonRencontres']],
]);
// Les colonnes dont la valeur n'existe pas quand le plafond a priori est
// inconnu : elles s'écrivent « inconnu », jamais « — » (§ 12.6).
const SELON_A_PRIORI = new Set(['ecartItineraireMax', 'ecartAuPlafondAPriori']);
@ -68,10 +95,12 @@ const TEXTUELLES = new Set(['proposition', 'certificat', 'imposes', 'commandes']
/** Les colonnes du tableau, dans l'ordre d'affichage : le rang et
* l'identifiant, puis les mesures — les trois minimums des rencontres en
* tête —, le certificat, les imposés et les commandes. Chacune porte son nom,
* tête, celles des objectifs et des animateurs en dernier —, le certificat,
* les imposés et les commandes ; toutes celles qu'un tableau peut porter,
* tableau() rendant celles que porte le sien. Chacune porte son nom,
* la clé de son en-tête, qui nomme son unité, celle de son explication,
* le texte de l'infobulle de son en-tête et du critère qui la désigne, et
* nombre, vrai quand ses cellules portent un nombre. */
* le texte de l'infobulle de son en-tête et de la valeur appliquée qui la
* désigne, et nombre, vrai quand ses cellules portent un nombre. */
export const COLONNES = Object.freeze(
['rang', 'proposition', ...LECTURES.keys(), 'imposes', 'commandes'].map((nom) =>
Object.freeze({ nom, cle: `propositions.colonne.${nom}`, explication: `propositions.explication.${nom}`, nombre: !TEXTUELLES.has(nom) }),
@ -86,44 +115,64 @@ const GROUPES = [
['repetitions', ['rencontresRepetees', 'maxRencontresPaire', 'retoursChoisis']],
['diversite', ['appartenancesVues', 'totalRedondance', 'diversite']],
['manque', ['manqueMax', 'effectifManque', 'ecartItineraireMax', 'ecartAuPlafondAPriori']],
['objectifs', ['jamaisServies', 'horsObjectifMax', 'toursHorsObjectif', 'toursSelonObjectif', 'animateursRencontres', 'animateursNonRencontres']],
];
// Les segments de la rangée des groupes, dans l'ordre de COLONNES : un
// groupe couvre ses colonnes d'un seul segment, une colonne hors groupe
// forme le sien, sans clé. Un groupe dont les colonnes ne se suivent pas
// dans COLONNES lève au chargement du module.
function segmenter() {
const noms = COLONNES.map(({ nom }) => nom);
// Les segments de la rangée des groupes sur les colonnes noms, une suite de
// COLONNES : un groupe couvre celles de ses colonnes que noms porte d'un seul
// segment, une colonne hors groupe forme le sien, sans clé ; un groupe dont
// noms ne porte aucune colonne n'a pas de segment. Un groupe dont les
// colonnes ne se suivent pas dans noms lève : au chargement du module pour
// COLONNES entier.
function segmenter(noms) {
const segments = [];
for (let rang = 0; rang < noms.length; ) {
const groupe = GROUPES.find(([, colonnes]) => colonnes[0] === noms[rang]);
const groupe = GROUPES.find(([, colonnes]) => colonnes.includes(noms[rang]));
if (groupe === undefined) {
if (GROUPES.some(([, colonnes]) => colonnes.includes(noms[rang]))) throw new Error(`colonne groupée hors de son groupe : ${noms[rang]}`);
segments.push({ cle: null, colonnes: [noms[rang]] });
rang += 1;
continue;
}
const [nom, colonnes] = groupe;
colonnes.forEach((colonne, decalage) => {
const portees = colonnes.filter((colonne) => noms.includes(colonne));
portees.forEach((colonne, decalage) => {
if (noms[rang + decalage] !== colonne) throw new Error(`groupe ${nom} : colonnes non voisines`);
});
segments.push({ cle: `propositions.groupe.${nom}`, colonnes });
rang += colonnes.length;
segments.push({ cle: `propositions.groupe.${nom}`, colonnes: portees });
rang += portees.length;
}
return segments.map(({ cle, colonnes }) => Object.freeze({ cle, etendue: colonnes.length, colonnes: Object.freeze([...colonnes]) }));
return Object.freeze(
segments.map(({ cle, colonnes }) => Object.freeze({ cle, etendue: colonnes.length, colonnes: Object.freeze([...colonnes]) })),
);
}
/** La rangée des groupes d'en-têtes, au-dessus de celle des colonnes :
* { cle, etendue, colonnes } par segment, dans l'ordre de COLONNES — cle,
* la clé du libellé du groupe, null pour une colonne hors groupe ;
* etendue, le nombre de colonnes couvertes ; colonnes, leurs noms. Les
* segments couvrent chaque colonne une fois. */
export const GROUPES_COLONNES = Object.freeze(segmenter());
/** La rangée des groupes d'en-têtes de COLONNES entier, au-dessus de celle
* des colonnes : { cle, etendue, colonnes } par segment, dans l'ordre de
* COLONNES — cle, la clé du libellé du groupe, null pour une colonne hors
* groupe ; etendue, le nombre de colonnes couvertes ; colonnes, leurs noms.
* Les segments couvrent chaque colonne une fois. tableau() rend ceux des
* colonnes que porte le sien. */
export const GROUPES_COLONNES = segmenter(COLONNES.map(({ nom }) => nom));
// Les colonnes que porte le tableau d'une table d'évaluation, dans l'ordre de
// COLONNES : chacune hors de SOUS_CONDITION, et celles dont la condition
// tient sur l'état des critères de la table, {nom, coche, sansObjet,
// valeurs}. Un critère compte quand il est coché et a un objet.
function colonnesPortees(criteres) {
const comptent = new Map(criteres.filter(({ coche, sansObjet }) => coche && !sansObjet).map(({ nom, valeurs }) => [nom, valeurs]));
return COLONNES.filter(({ nom }) => {
const condition = SOUS_CONDITION.get(nom);
if (condition === undefined) return true;
const [critere, valeur] = condition;
const valeurs = comptent.get(critere);
return valeurs !== undefined && (valeur === null || valeurs.includes(valeur));
});
}
/**
* La clé de l'explication d'une colonne : le texte de l'infobulle de son
* en-tête, et celui du nom de critère qui la désigne dans la phrase de
* l'ordre (colonneDuCritere) — un critère et sa colonne partagent la même.
* en-tête, et celui du nom qui la désigne dans la phrase de l'ordre
* (colonneDeValeur) — une valeur appliquée et sa colonne partagent la même.
* Lève TypeError pour un nom qui n'est pas une colonne de COLONNES.
*
* @param {string} nom le nom d'une colonne
@ -154,12 +203,12 @@ function certificat(ligne) {
return { colonne: 'certificat', valeur, cle, decimales: 0 };
}
// Les cellules de mesure d'une ligne comparable, dans l'ordre de LECTURES.
// L'a priori est inconnu quand troisChiffres ne porte pas d'écart
// d'itinéraire du tout.
function cellules(ligne) {
// Les cellules de mesure d'une ligne comparable, une par colonne de mesure
// portée, dans l'ordre de LECTURES. L'a priori est inconnu quand
// troisChiffres ne porte pas d'écart d'itinéraire du tout.
function cellules(ligne, portees) {
const inconnu = ligne.troisChiffres.ecartItineraireMax === null;
return [...LECTURES].map(([colonne, lire]) => {
return [...LECTURES].filter(([colonne]) => portees.has(colonne)).map(([colonne, lire]) => {
if (colonne === 'certificat') return certificat(ligne);
if (inconnu && SELON_A_PRIORI.has(colonne)) return { colonne, valeur: null, cle: INCONNU, decimales: 0 };
return nombre(colonne, lire(ligne));
@ -181,14 +230,11 @@ const codeDe = (raison) => ({ cle: raison.code, details: raison });
// null quand elle n'en nomme aucune.
const raisonDe = (raison) => ({ ...codeDe(raison), participant: raison.participant ?? null });
// Les colonnes que couvre la cellule des raisons d'une ligne hors
// classement : celles des mesures et celle des imposés.
const ETENDUE_RAISONS = LECTURES.size + 1;
// La ligne d'un placement mesuré, ou celle d'un placement hors classement.
// rang : la place dans le classement, à partir de 1 ; null hors classement
// et pour le retenu.
function ligneDe(ligne, table, idRetenu, rang = null) {
// portees : les noms des colonnes que porte le tableau ; rang : la place
// dans le classement, à partir de 1 ; null hors classement et pour le
// retenu.
function ligneDe(ligne, table, idRetenu, portees, rang = null) {
const retenue = ligne.id === idRetenu;
if (ligne.statut !== 'comparable') {
return {
@ -205,83 +251,106 @@ function ligneDe(ligne, table, idRetenu, rang = null) {
rang,
retenue,
comparable: true,
cellules: cellules(ligne),
cellules: cellules(ligne, portees),
imposes: imposes(ligne, table.imposes),
violations: ligne.violations.map(codeDe),
};
}
// La colonne que désigne chaque critère du classement (§ 5.7) : celle qui
// porte la grandeur qu'il compare ; null pour les tours hors objectif et les
// animateurs non rencontrés, que le tableau ne porte pas en colonne. La Map
// ne sert qu'à retrouver la colonne d'un critère ; la phrase suit l'ordre que
// le classement rend.
const COLONNE_DU_CRITERE = new Map([
// La colonne que désigne chaque valeur qui classe (§ 5.7, A11) : celle qui
// la porte ; l'identifiant croissant, le dernier départage, désigne la
// colonne des propositions. La Map ne sert qu'à retrouver la colonne d'une
// valeur ; la phrase suit l'ordre que le classement rend.
const COLONNE_DE_VALEUR = new Map([
['ecartAuPlafondAPrioriMax', 'ecartAuPlafondAPriori'],
['excedentCollisions', 'excedentCollisions'],
['collisionsCumulees', 'collisionsCumulees'],
['toursHorsObjectif', null],
['rencontresRepetees', 'rencontresRepetees'],
['animateursNonRencontres', null],
['jamaisServies', 'jamaisServies'],
['horsObjectifMax', 'horsObjectifMax'],
['toursHorsObjectif', 'toursHorsObjectif'],
['rencontresRepeteesChoisies', 'rencontresRepetees'],
['retoursChoisis', 'retoursChoisis'],
['animateursNonRencontres', 'animateursNonRencontres'],
['redondance', 'totalRedondance'],
['identifiant', 'proposition'],
]);
/**
* La colonne du tableau que désigne un critère du classement, ou
* 'identifiant', le dernier départage, qui désigne la colonne des
* propositions ; null pour un critère du registre que le tableau ne porte
* pas en colonne. Lève TypeError pour un nom que la table ne connaît pas :
* un critère ajouté au classement doit dire laquelle il compare, ou
* qu'il n'en a pas.
* La colonne du tableau que désigne une valeur qui classe (valeursQuiClassent,
* criteres.js), ou 'identifiant', le dernier départage, qui désigne la
* colonne des propositions. Lève TypeError pour un nom que la table ne
* connaît pas : une valeur ajoutée au classement doit dire laquelle elle
* compare.
*
* @param {string} critere un nom de CRITERES (criteres.js), ou 'identifiant'
* @returns {string|null} le nom d'une colonne de COLONNES, ou null
* @param {string} valeur une valeur qui classe, ou 'identifiant'
* @returns {string} le nom d'une colonne de COLONNES
*/
export function colonneDuCritere(critere) {
const colonne = COLONNE_DU_CRITERE.get(critere);
if (colonne === undefined) throw new TypeError(`critère sans colonne : ${critere}`);
export function colonneDeValeur(valeur) {
const colonne = COLONNE_DE_VALEUR.get(valeur);
if (colonne === undefined) throw new TypeError(`valeur sans colonne : ${valeur}`);
return colonne;
}
// Les noms des critères appliqués, palier après palier : la liste aplatie
// des criteresAppliques que rend classer.
const aplatir = (criteresAppliques) => criteresAppliques.flatMap(({ criteres }) => criteres.map(({ nom }) => nom));
// Un morceau de phrase : un texte de liaison, ou un nom qui désigne une
// colonne ; un nom de critère sans colonne s'affiche comme un texte.
const liaison = (cle, details = {}) => ({ cle, details, critere: null, colonne: null });
const designant = (critere, cle) => ({ cle, details: {}, critere, colonne: colonneDuCritere(critere) });
// colonne — celui d'une valeur appliquée, sous la clé
// moteur.valeur.<valeur>, avec le critère qui la classe.
const liaison = (cle, details = {}) => ({ cle, details, critere: null, valeur: null, colonne: null });
const designant = (critere, valeur, cle = `moteur.valeur.${valeur}`) => ({ cle, details: {}, critere, valeur, colonne: colonneDeValeur(valeur) });
// La phrase des critères appliqués, morceau par morceau : le début, chaque
// critère de la liste aplatie par son nom moteur.critere.<nom>, puis
// l'identifiant croissant, séparés, et la fin. Bout à bout, les morceaux
// traduits composent le texte de propositions.ordre.criteres sur la liste
// aplatie : sous l'ordre par défaut et les quatre cases cochées, celui de
// l'ordre fixe de référence.
function phraseDesCriteres(criteresAppliques) {
const noms = [
...aplatir(criteresAppliques).map((critere) => designant(critere, `moteur.critere.${critere}`)),
designant('identifiant', 'propositions.ordre.identifiant'),
// Des listes de morceaux mises bout à bout, le morceau separateur entre deux.
const joindre = (listes, separateur) => listes.flatMap((morceaux, rang) => (rang === 0 ? morceaux : [separateur(), ...morceaux]));
// Les noms d'un critère appliqué, {nom, valeurs, poids} : chaque valeur
// qu'il classe, dans l'ordre, séparées par « puis » comme deux paliers.
const nomsDuCritere = ({ nom, valeurs }) =>
joindre(
valeurs.map((valeur) => [designant(nom, valeur)]),
() => liaison('propositions.ordre.separateur'),
);
// Un palier appliqué en morceaux : les noms de son critère, seul ; pour un
// palier partagé, « ensemble, » puis chaque critère suivi de son poids,
// séparés par une virgule.
function palierEnMorceaux({ criteres }) {
if (criteres.length === 1) return nomsDuCritere(criteres[0]);
return [
liaison('propositions.ordre.ensemble'),
...joindre(
criteres.map((critere) => [...nomsDuCritere(critere), liaison('propositions.ordre.poids', { poids: critere.poids })]),
() => liaison('propositions.ordre.virgule'),
),
];
}
// La phrase des critères appliqués, morceau par morceau (A11) : le début,
// chaque palier appliqué dans l'ordre, puis l'identifiant croissant, séparés
// par « puis », et la fin. Chaque nom est celui de la valeur que le palier
// applique — « retours » pour les rencontres répétées sous « nouvelle
// table » seule. Sous l'ordre par défaut et les quatre cases cochées, sans
// objectif ni case des animateurs, chaque palier porte un critère qui
// classe une valeur du nom de son critère, et les morceaux traduits bout à
// bout composent le texte de propositions.ordre.criteres, celui de l'ordre
// fixe de référence.
function phraseDesCriteres(criteresAppliques) {
const paliers = [...criteresAppliques.map(palierEnMorceaux), [designant('identifiant', 'identifiant', 'propositions.ordre.identifiant')]];
return [
liaison('propositions.ordre.debut'),
...noms.flatMap((nom, rang) => (rang === 0 ? [nom] : [liaison('propositions.ordre.separateur'), nom])),
...joindre(paliers, () => liaison('propositions.ordre.separateur')),
liaison('propositions.ordre.fin'),
];
}
// Le palier qui prend la place du palier sauté : ses critères {nom, poids},
// ceux du palier appliqué au rang rangSaute ; null quand aucun palier ne
// suit le palier sauté.
// Le palier qui prend la place du palier sauté : ses critères {nom, poids,
// valeurs}, ceux du palier appliqué au rang rangSaute ; null quand aucun
// palier ne suit le palier sauté.
function palierAppliqueASaPlace({ criteresAppliques, rangSaute }) {
const palier = criteresAppliques[rangSaute];
return palier === undefined ? null : palier.criteres.map(({ nom, poids }) => ({ nom, poids }));
return palier === undefined ? null : palier.criteres.map(({ nom, poids, valeurs }) => ({ nom, poids, valeurs: [...valeurs] }));
}
// Les phrases de l'ordre (§ 5.7), chacune une liste de morceaux : quand le
// plafond a priori manque, le critère sauté et le palier appliqué à sa
// place, s'il en est un ; puis les critères appliqués.
// place, s'il en est un ; puis les paliers appliqués.
function ordre(classement) {
const { criteresAppliques, critereSaute } = classement;
const phrases = [];
@ -300,13 +369,18 @@ function ordre(classement) {
* placement de sa proposition d'origine (retenuAjuste) ; il choisit le
* libellé de la ligne du retenu
* @returns {{ordre: Array<Array<{cle: string, details: Object, critere: string|null,
* colonne: string|null}>>, colonnes: typeof COLONNES,
* etendueRaisons: number, lignes: Object[], horsClassement: Object[], retenu: Object|null}}
* valeur: string|null, colonne: string|null}>>, colonnes: typeof COLONNES,
* groupes: typeof GROUPES_COLONNES, etendueRaisons: number, lignes: Object[],
* horsClassement: Object[], retenu: Object|null}}
* ordre : les phrases de l'ordre, chacune une liste de morceaux que t
* traduit et que l'affichage met bout à bout ; un morceau qui nomme un
* critère, ou l'identifiant croissant, porte critere et la colonne qu'il
* désigne, null pour un texte de liaison ; lignes : les comparables dans
* l'ordre du classement, chacune { id, rang, retenue, comparable: true,
* traduit et que l'affichage met bout à bout ; un morceau qui nomme une
* valeur appliquée, ou l'identifiant croissant, porte le critère qui la
* classe, la valeur et la colonne qu'elle désigne — 'identifiant' pour
* le dernier départage —, null pour un texte de liaison ; colonnes : celles
* de COLONNES que le tableau porte, les colonnes des objectifs et des
* animateurs selon l'état des critères de la table ; groupes : les
* segments de la rangée des groupes sur ces colonnes ; lignes : les
* comparables dans l'ordre du classement, chacune { id, rang, retenue, comparable: true,
* cellules, imposes, violations } ;
* horsClassement : les autres par identifiant, { id, rang: null, retenue,
* comparable: false, statut, raisons } — statut la clé de « non comparable »,
@ -319,24 +393,29 @@ function ordre(classement) {
*/
export function tableau(tableEvaluation, { ajuste = false } = {}) {
const idRetenu = tableEvaluation.retenu?.id ?? null;
const colonnes = colonnesPortees(tableEvaluation.criteres);
const portees = new Set(colonnes.map(({ nom }) => nom));
// Les comparables ouvrent la liste, dans l'ordre du classement : leur
// place y est leur rang.
const toutes = tableEvaluation.propositions.map((ligne, place) => ligneDe(ligne, tableEvaluation, idRetenu, place + 1));
const toutes = tableEvaluation.propositions.map((ligne, place) => ligneDe(ligne, tableEvaluation, idRetenu, portees, place + 1));
return {
ordre: ordre(tableEvaluation.classement),
colonnes: COLONNES,
etendueRaisons: ETENDUE_RAISONS,
colonnes,
groupes: segmenter(colonnes.map(({ nom }) => nom)),
// La cellule des raisons d'une ligne hors classement couvre les colonnes
// de mesure portées et celle des imposés.
etendueRaisons: [...LECTURES.keys()].filter((nom) => portees.has(nom)).length + 1,
lignes: toutes.filter(({ comparable }) => comparable),
horsClassement: toutes.filter(({ comparable }) => !comparable),
retenu: tableEvaluation.retenu === null ? null : ligneDuRetenu(tableEvaluation, idRetenu, ajuste),
retenu: tableEvaluation.retenu === null ? null : ligneDuRetenu(tableEvaluation, idRetenu, portees, ajuste),
};
}
// La ligne du retenu, et son libellé : « tel que » la proposition d'origine,
// ou « ajusté » depuis elle.
function ligneDuRetenu(tableEvaluation, idRetenu, ajuste) {
function ligneDuRetenu(tableEvaluation, idRetenu, portees, ajuste) {
const cle = ajuste ? 'propositions.retenu.ajuste' : 'propositions.retenu.origine';
return { ...ligneDe(tableEvaluation.retenu, tableEvaluation, idRetenu), libelle: { cle, details: { proposition: idRetenu } } };
return { ...ligneDe(tableEvaluation.retenu, tableEvaluation, idRetenu, portees), libelle: { cle, details: { proposition: idRetenu } } };
}
/**
@ -380,9 +459,10 @@ function egaux(a, b) {
// remplacerait.
const CHAMPS_DU_PLACEMENT = ['siegesAttribues', 'tables', 'capacites', 'tours', 'participants', 'placement'];
/** Vrai quand un retenu existe et ne porte plus le placement de sa proposition d'origine :
* tables, capacités, tours, participants ou placement diffèrent, en profondeur, de ceux
* de la proposition retenu.proposition, ou celle-ci n'est plus dans la liste. */
/** Vrai quand un retenu existe et ne porte plus le placement de sa
* proposition d'origine : tables, capacités, tours, participants ou
* placement diffèrent, en profondeur, de ceux de la proposition
* retenu.proposition, ou celle-ci n'est plus dans la liste. */
export function retenuAjuste(charge) {
const { retenu } = charge;
if (retenu === null) return false;
@ -430,9 +510,9 @@ export function demandeConfirmation({ refus, charge }, id) {
// dans le modèle pur et tenu par l'immuabilité de son entrée.
const evaluees = new WeakMap();
// Vrai quand chaque objet atteint depuis valeur est figé. Un objet figé ne
// redevient jamais modifiable : vrai une fois, vrai pour toujours.
function figeePartout(valeur) {
/** Vrai quand chaque objet atteint depuis valeur est figé. Un objet figé ne
* redevient jamais modifiable : vrai une fois, vrai pour toujours. */
export function figeePartout(valeur) {
if (valeur === null || typeof valeur !== 'object') return true;
if (!Object.isFrozen(valeur)) return false;
for (const cle of Object.keys(valeur)) if (!figeePartout(valeur[cle])) return false;

View file

@ -21,6 +21,7 @@ import { PLAN_PARFAIT_PETITE } from '../../demo/catalogue.js';
import { texteLivre } from '../../demo/livrees.js';
import { CRITERES, classer } from '../../moteur/classement.js';
import { indexerPlan, normaliser } from '../../moteur/configuration.js';
import { CRITERES as REGISTRE, MODES_OBJECTIFS, valeursQuiClassent } from '../../moteur/criteres.js';
import { mesurer } from '../../moteur/indicateurs.js';
import { ecartsAuPlafondAPriori, minimumAtteint, troisChiffres } from '../../moteur/manque.js';
import { plafondsAPriori, plafondsRealises } from '../../moteur/plafond.js';
@ -34,7 +35,7 @@ import {
GROUPES_COLONNES,
TAILLE_PAGE,
changerDePage,
colonneDuCritere,
colonneDeValeur,
demandeConfirmation,
evaluation,
explicationDeColonne,
@ -123,6 +124,49 @@ const DEUX_COLLEGUES = [
[[1, 2, 6], [3, 4, 5]],
];
// Les colonnes du tableau sans objectif ni case des animateurs, dans l'ordre
// d'affichage : celles que le tableau portait avant le sixième groupe.
const COLONNES_PAR_DEFAUT = [
'rang',
'proposition',
'rencontresTous',
'rencontresMobiles',
'rencontresAncres',
'collisionsCumulees',
'pairesDistinctes',
'excedentCollisions',
'rencontresRepetees',
'maxRencontresPaire',
'retoursChoisis',
'appartenancesVues',
'totalRedondance',
'diversite',
'manqueMax',
'effectifManque',
'ecartItineraireMax',
'ecartAuPlafondAPriori',
'certificat',
'imposes',
'commandes',
];
// Les colonnes du sixième groupe, les objectifs des tables et les
// animateurs, dans l'ordre d'affichage ; les deux premières ne paraissent
// jamais ensemble, chacune sous son mode.
const SIXIEME = ['jamaisServies', 'horsObjectifMax', 'toursHorsObjectif', 'toursSelonObjectif', 'animateursRencontres', 'animateursNonRencontres'];
// La petite démonstration au conflit inévitable, les personnes 1 à 6
// « finance », la première table attendant « finance » pour la soirée ; les
// réglages donnés s'ajoutent à ceux de la charge.
function petiteAObjectifs(reglages = {}) {
const charge = structuredClone(chargeDemo('petite-conflit'));
for (const participant of charge.participants) if (participant.id <= 6) participant.etiquettes = ['finance'];
const [premiere] = [...charge.tables].sort((a, b) => a.id - b.id);
premiere.objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }];
Object.assign(charge.reglages, reglages);
return charge;
}
// Trois plans de la petite démonstration au conflit inévitable, nommés par
// leurs collisions (cumulées, distinctes), au même plus grand écart au
// plafond a priori : ceux de l'épreuve de la table d'évaluation.
@ -227,7 +271,8 @@ describe('tableau : chaque cellule porte la valeur de la table d’évaluation (
test('chaque cellule égale la valeur de sa ligne ; un nombre se met en forme, null porte la clé de « — »', () => {
const table = evaluer(generee(chargeDemo('petite-conflit'), { graine: 5, nombre: 3, arret: 400 }));
const vue = tableau(table);
assert.deepEqual(vue.colonnes, COLONNES);
assert.deepEqual(vue.colonnes.map(({ nom }) => nom), COLONNES_PAR_DEFAUT);
for (const colonne of vue.colonnes) assert.ok(COLONNES.includes(colonne), colonne.nom);
assert.equal(vue.lignes.length, 3);
assert.deepEqual(vue.lignes.map(({ id }) => id), table.propositions.map(({ id }) => id));
let vues = 0;
@ -419,18 +464,40 @@ describe('tableau : l’ordre écrit (§ 5.7, § 17 point 4)', () => {
['identifiant', 'proposition'],
]);
for (const morceau of vue.ordre[0]) {
if (morceau.colonne === null) assert.equal(morceau.critere, null, morceau.cle);
else assert.equal(t(morceau.cle, morceau.details), morceau.critere === 'identifiant' ? "l'identifiant croissant" : t(`moteur.critere.${morceau.critere}`));
if (morceau.colonne === null) {
assert.equal(morceau.critere, null, morceau.cle);
assert.equal(morceau.valeur, null, morceau.cle);
continue;
}
// Sous le défaut, la valeur appliquée porte le nom du critère.
const attendu = morceau.critere === 'identifiant' ? "l'identifiant croissant" : t(`moteur.critere.${morceau.critere}`);
assert.equal(t(morceau.cle, morceau.details), attendu);
if (morceau.critere !== 'identifiant') assert.equal(morceau.cle, `moteur.valeur.${morceau.valeur}`);
}
});
test('colonneDuCritere : chaque critère de CRITERES désigne une colonne de COLONNES ; un critère sans colonne lève', () => {
test('colonneDeValeur : chaque valeur qui classe, sous tout mode et toute combinaison des cases, désigne une colonne de COLONNES ; une valeur inconnue lève', () => {
const noms = new Set(COLONNES.map(({ nom }) => nom));
assert.equal(CRITERES.length, 5);
for (const critere of CRITERES) assert.ok(noms.has(colonneDuCritere(critere)), critere);
assert.ok(noms.has(colonneDuCritere('identifiant')));
assert.equal(colonneDuCritere('identifiant'), 'proposition');
assert.throws(() => colonneDuCritere('moyenneRencontres'), TypeError);
const valeurs = new Set();
for (const mode of MODES_OBJECTIFS) {
for (let masque = 0; masque < 16; masque += 1) {
const contraintes = {
separerAppartenances: (masque & 1) !== 0,
nouveauxVoisins: (masque & 2) !== 0,
nouvelleTable: (masque & 4) !== 0,
varierAppartenances: (masque & 8) !== 0,
};
for (const nom of REGISTRE) for (const valeur of valeursQuiClassent(nom, contraintes, mode)) valeurs.add(valeur);
}
}
assert.equal(valeurs.size, 10);
for (const valeur of valeurs) assert.ok(noms.has(colonneDeValeur(valeur)), valeur);
assert.equal(colonneDeValeur('rencontresRepeteesChoisies'), 'rencontresRepetees');
assert.equal(colonneDeValeur('retoursChoisis'), 'retoursChoisis');
assert.equal(colonneDeValeur('redondance'), 'totalRedondance');
assert.equal(colonneDeValeur('identifiant'), 'proposition');
assert.throws(() => colonneDeValeur('moyenneRencontres'), TypeError);
assert.throws(() => colonneDeValeur('rencontresRepetees'), TypeError);
});
test('a priori inconnu : la phrase nomme le critère sauté et « excédent de collisions » appliqué à sa place ; les critères appliqués désignent toujours leur colonne', () => {
@ -440,8 +507,9 @@ describe('tableau : l’ordre écrit (§ 5.7, § 17 point 4)', () => {
assert.deepEqual(vue.ordre[0], [
{
cle: 'propositions.ordre.saute',
details: { saute: 'ecartAuPlafondAPrioriMax', applique: [{ nom: 'excedentCollisions', poids: 1 }] },
details: { saute: 'ecartAuPlafondAPrioriMax', applique: [{ nom: 'excedentCollisions', poids: 1, valeurs: ['excedentCollisions'] }] },
critere: null,
valeur: null,
colonne: null,
},
]);
@ -502,7 +570,7 @@ describe('tableau : l’ordre écrit (§ 5.7, § 17 point 4)', () => {
);
});
test('les objectifs comptés se nomment à leur palier, en texte : colonneDuCritere rend null pour les critères que le tableau ne porte pas en colonne', () => {
test('les objectifs comptés se nomment à leur palier et désignent la colonne des tours hors objectif', () => {
const vue = tableau(evaluer(generee(aObjectifs(), { graine: 3, nombre: 2, arret: 200 })));
assert.equal(
phraseDe(vue.ordre[0]),
@ -511,12 +579,16 @@ describe('tableau : l’ordre écrit (§ 5.7, § 17 point 4)', () => {
"puis l'identifiant croissant.",
);
const horsObjectif = vue.ordre[0].find(({ critere }) => critere === 'toursHorsObjectif');
assert.deepEqual(horsObjectif, { cle: 'moteur.critere.toursHorsObjectif', details: {}, critere: 'toursHorsObjectif', colonne: null });
assert.equal(colonneDuCritere('toursHorsObjectif'), null);
assert.equal(colonneDuCritere('animateursNonRencontres'), null);
assert.deepEqual(horsObjectif, {
cle: 'moteur.valeur.toursHorsObjectif',
details: {},
critere: 'toursHorsObjectif',
valeur: 'toursHorsObjectif',
colonne: 'toursHorsObjectif',
});
});
test('sous un ordre réglé, la phrase suit les paliers, aplatis ; a priori inconnu, le palier qui suit l’écart prend sa place, nommé en entier', () => {
test('sous un ordre réglé, la phrase dit les paliers ; a priori inconnu, le palier qui suit l’écart prend sa place, nommé en entier', () => {
const priorites = [
[e('toursHorsObjectif')],
[e('collisionsCumulees')],
@ -529,13 +601,16 @@ describe('tableau : l’ordre écrit (§ 5.7, § 17 point 4)', () => {
assert.equal(
phraseDe(tableau(evaluer(charge)).ordre[0]),
"Ordre du classement, chaque critère au plus faible d'abord : tours hors objectif, puis collisions cumulées, " +
"puis écart au plafond a priori, puis excédent de collisions, puis redondance d'appartenance, " +
"puis écart au plafond a priori, puis ensemble, excédent de collisions (poids 1), redondance d'appartenance (poids 2), " +
"puis rencontres répétées, puis l'identifiant croissant.",
);
const inconnu = tableau(evaluer(charge, { plafondsAPriori: null }));
assert.deepEqual(inconnu.ordre[0][0].details, {
saute: 'ecartAuPlafondAPrioriMax',
applique: [{ nom: 'excedentCollisions', poids: 1 }, { nom: 'redondance', poids: 2 }],
applique: [
{ nom: 'excedentCollisions', poids: 1, valeurs: ['excedentCollisions'] },
{ nom: 'redondance', poids: 2, valeurs: ['redondance'] },
],
});
assert.equal(
phraseDe(inconnu.ordre[0]),
@ -546,6 +621,251 @@ describe('tableau : l’ordre écrit (§ 5.7, § 17 point 4)', () => {
});
});
describe('tableau : la phrase dit les paliers et les valeurs appliquées (§ 5.7, A11)', () => {
const phraseDe = (morceaux) => morceaux.map(({ cle, details }) => t(cle, details)).join('');
const designes = (morceaux) => morceaux.filter(({ colonne }) => colonne !== null).map(({ critere, valeur, colonne }) => [critere, valeur, colonne]);
const e = (critere, poids = 1) => ({ critere, poids });
const DEBUT = "Ordre du classement, chaque critère au plus faible d'abord : ";
test('« nouvelle table » cochée seule : le palier des rencontres répétées se nomme « retours » et désigne la colonne des retours choisis', () => {
const charge = structuredClone(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT));
for (const cle of ['separerAppartenances', 'nouveauxVoisins', 'varierAppartenances']) charge.reglages[cle] = false;
const vue = tableau(evaluer(charge));
assert.equal(phraseDe(vue.ordre[0]), `${DEBUT}écart au plafond a priori, puis retours, puis l'identifiant croissant.`);
assert.deepEqual(designes(vue.ordre[0]), [
['ecartAuPlafondAPrioriMax', 'ecartAuPlafondAPrioriMax', 'ecartAuPlafondAPriori'],
['rencontresRepetees', 'retoursChoisis', 'retoursChoisis'],
['identifiant', 'identifiant', 'proposition'],
]);
});
test('a priori inconnu, le critère sauté : le palier appliqué à sa place se nomme par ses valeurs — « retours » sous « nouvelle table » seule, les deux valeurs sous « chacun au moins une fois »', () => {
const PHRASE = 'Critère sauté, faute de plafond a priori par personne : écart au plafond a priori. Appliqué à sa place : ';
const seule = structuredClone(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT));
for (const cle of ['separerAppartenances', 'nouveauxVoisins', 'varierAppartenances']) seule.reglages[cle] = false;
const [saute] = tableau(evaluer(seule, { plafondsAPriori: null })).ordre[0];
assert.equal(t(saute.cle, saute.details), `${PHRASE}retours.`);
const priorites = [
[e('ecartAuPlafondAPrioriMax')],
[e('toursHorsObjectif')],
[e('excedentCollisions')],
[e('collisionsCumulees')],
[e('rencontresRepetees')],
[e('redondance')],
[e('animateursNonRencontres')],
];
const chacun = generee(petiteAObjectifs({ modeObjectifs: 'chacun', priorites }), { graine: 3, nombre: 2, arret: 200 });
const [sauteChacun] = tableau(evaluer(chacun, { plafondsAPriori: null })).ordre[0];
assert.equal(t(sauteChacun.cle, sauteChacun.details), `${PHRASE}personnes jamais servies, puis tours hors objectif.`);
});
test('« chacun au moins une fois » : « personnes jamais servies, puis tours hors objectif », deux noms du même critère, chacun sa colonne', () => {
const vue = tableau(evaluer(generee(petiteAObjectifs({ modeObjectifs: 'chacun' }), { graine: 3, nombre: 2, arret: 200 })));
assert.equal(
phraseDe(vue.ordre[0]),
`${DEBUT}écart au plafond a priori, puis excédent de collisions, puis collisions cumulées, ` +
"puis personnes jamais servies, puis tours hors objectif, puis rencontres répétées, puis redondance d'appartenance, " +
"puis l'identifiant croissant.",
);
assert.deepEqual(
designes(vue.ordre[0]).filter(([critere]) => critere === 'toursHorsObjectif'),
[
['toursHorsObjectif', 'jamaisServies', 'jamaisServies'],
['toursHorsObjectif', 'toursHorsObjectif', 'toursHorsObjectif'],
],
);
});
test('« l’équité d’abord » : « plus de tours hors objectif d’une personne, puis tours hors objectif », chacun sa colonne', () => {
const vue = tableau(evaluer(generee(petiteAObjectifs({ modeObjectifs: 'minimum' }), { graine: 3, nombre: 2, arret: 200 })));
assert.ok(
phraseDe(vue.ordre[0]).includes("puis collisions cumulées, puis plus de tours hors objectif d'une personne, puis tours hors objectif, puis rencontres répétées"),
phraseDe(vue.ordre[0]),
);
assert.deepEqual(
designes(vue.ordre[0]).filter(([critere]) => critere === 'toursHorsObjectif'),
[
['toursHorsObjectif', 'horsObjectifMax', 'horsObjectifMax'],
['toursHorsObjectif', 'toursHorsObjectif', 'toursHorsObjectif'],
],
);
});
test('un palier partagé : « ensemble, », puis chaque nom suivi de son poids ; chaque nom désigne sa colonne, les poids restent du texte', () => {
const charge = structuredClone(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT));
charge.reglages.priorites = [
[e('ecartAuPlafondAPrioriMax')],
[e('excedentCollisions', 3), e('redondance', 2)],
[e('collisionsCumulees')],
[e('toursHorsObjectif')],
[e('rencontresRepetees')],
[e('animateursNonRencontres')],
];
const [morceaux] = tableau(evaluer(charge)).ordre;
assert.equal(
phraseDe(morceaux),
`${DEBUT}écart au plafond a priori, puis ensemble, excédent de collisions (poids 3), redondance d'appartenance (poids 2), ` +
"puis collisions cumulées, puis rencontres répétées, puis l'identifiant croissant.",
);
const debut = morceaux.findIndex(({ cle }) => cle === 'propositions.ordre.ensemble');
assert.deepEqual(morceaux.slice(debut, debut + 5), [
{ cle: 'propositions.ordre.ensemble', details: {}, critere: null, valeur: null, colonne: null },
{ cle: 'moteur.valeur.excedentCollisions', details: {}, critere: 'excedentCollisions', valeur: 'excedentCollisions', colonne: 'excedentCollisions' },
{ cle: 'propositions.ordre.poids', details: { poids: 3 }, critere: null, valeur: null, colonne: null },
{ cle: 'propositions.ordre.virgule', details: {}, critere: null, valeur: null, colonne: null },
{ cle: 'moteur.valeur.redondance', details: {}, critere: 'redondance', valeur: 'redondance', colonne: 'totalRedondance' },
]);
});
test('les animateurs comptés : « animateurs non rencontrés » à leur palier, qui désigne sa colonne', () => {
const charge = structuredClone(chargeDemo('grande'));
charge.reglages.rencontrerAnimateurs = true;
const vue = tableau(evaluer(generee(charge, { graine: 1, nombre: 1, arret: 2000 })));
assert.ok(phraseDe(vue.ordre[0]).includes("puis rencontres répétées, puis animateurs non rencontrés, puis redondance d'appartenance"), phraseDe(vue.ordre[0]));
assert.deepEqual(
designes(vue.ordre[0]).filter(([critere]) => critere === 'animateursNonRencontres'),
[['animateursNonRencontres', 'animateursNonRencontres', 'animateursNonRencontres']],
);
});
test('chaque nom de la phrase désigne une colonne présente du tableau, et un seul : sous chaque mode, chaque combinaison des cases, a priori connu ou inconnu', () => {
const base = avecPlans(petiteAObjectifs(), PLANS_CONFLIT);
let vues = 0;
for (const mode of MODES_OBJECTIFS) {
for (let masque = 0; masque < 16; masque += 1) {
const charge = structuredClone(base);
if (mode !== 'places') charge.reglages.modeObjectifs = mode;
['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable', 'varierAppartenances'].forEach((cle, rang) => {
charge.reglages[cle] = (masque & (1 << rang)) !== 0;
});
for (const options of [{}, { plafondsAPriori: null }]) {
const vue = tableau(evaluer(charge, options));
const presentes = new Set(vue.colonnes.map(({ nom }) => nom));
const designees = vue.ordre.flat().filter(({ colonne }) => colonne !== null);
assert.ok(designees.length >= 1);
for (const { valeur, colonne } of designees) assert.ok(presentes.has(colonne), `${mode} ${masque} ${valeur} → ${colonne}`);
assert.equal(new Set(designees.map(({ colonne }) => colonne)).size, designees.length, `${mode} ${masque}`);
vues += 1;
}
}
}
assert.equal(vues, 96);
});
});
describe('tableau : le sixième groupe, les objectifs des tables et les animateurs (A9, A11)', () => {
const noms = (vue) => vue.colonnes.map(({ nom }) => nom);
const duGroupe = (vue) => vue.groupes.find(({ cle }) => cle === 'propositions.groupe.objectifs')?.colonnes ?? null;
const sansSixieme = (vue) => SIXIEME.every((nom) => !noms(vue).includes(nom));
// Les colonnes de chaque ligne comparable et du retenu suivent celles de la
// vue : les mesures seules, sans le rang, la proposition, les imposés ni
// les commandes.
function exigerCellulesAlignees(vue) {
const mesures = noms(vue).filter((nom) => !['rang', 'proposition', 'imposes', 'commandes'].includes(nom));
assert.equal(vue.etendueRaisons, mesures.length + 1);
for (const ligne of [...vue.lignes, ...(vue.retenu?.comparable ? [vue.retenu] : [])]) {
assert.deepEqual(ligne.cellules.map(({ colonne }) => colonne), mesures, String(ligne.id));
}
}
test('sans objectif ni case des animateurs : aucune colonne du groupe ; les vingt et une colonnes et les cinq groupes d’avant', () => {
const vue = tableau(evaluer(generee(chargeDemo('petite-conflit'), { graine: 3, nombre: 2, arret: 200 })));
assert.deepEqual(noms(vue), COLONNES_PAR_DEFAUT);
assert.equal(duGroupe(vue), null);
assert.deepEqual(
vue.groupes.filter(({ cle }) => cle !== null).map(({ cle }) => cle),
['rencontres', 'collisions', 'repetitions', 'diversite', 'manque'].map((nom) => `propositions.groupe.${nom}`),
);
assert.deepEqual(vue.groupes, GROUPES_COLONNES.filter(({ colonnes }) => !SIXIEME.includes(colonnes[0])));
assert.equal(vue.etendueRaisons, 18);
exigerCellulesAlignees(vue);
});
test('« le plus de places servies » : les tours hors objectif et le minimum des tours selon l’objectif, après le manque et avant le certificat ; ni les personnes jamais servies ni le plus de tours hors objectif d’une personne', () => {
let charge = generee(petiteAObjectifs(), { graine: 3, nombre: 2, arret: 200 });
charge = executer(charge, 'retenirProposition', { id: 2 });
const table = evaluer(charge);
const vue = tableau(table);
assert.deepEqual(duGroupe(vue), ['toursHorsObjectif', 'toursSelonObjectif']);
assert.ok(!noms(vue).includes('jamaisServies'));
assert.ok(!noms(vue).includes('horsObjectifMax'));
assert.deepEqual(noms(vue).slice(16, 21), ['ecartItineraireMax', 'ecartAuPlafondAPriori', 'toursHorsObjectif', 'toursSelonObjectif', 'certificat']);
exigerCellulesAlignees(vue);
const lignes = [...vue.lignes.map((ligne, rang) => [ligne, table.propositions[rang]]), [vue.retenu, table.retenu]];
for (const [ligne, source] of lignes) {
assert.equal(cellule(ligne, 'toursHorsObjectif').valeur, source.mesures.objectifs.horsObjectif);
assert.equal(cellule(ligne, 'toursSelonObjectif').valeur, source.mesures.objectifs.agregat.tous.min);
assert.equal(cellule(ligne, 'toursHorsObjectif').cle, null);
}
});
test('« chacun au moins une fois » : les personnes jamais servies devant les deux ; « l’équité d’abord » : le plus de tours hors objectif d’une personne', () => {
for (const [mode, premiere, lire] of [
['chacun', 'jamaisServies', (objectifs) => objectifs.jamaisServies],
['minimum', 'horsObjectifMax', (objectifs) => objectifs.horsObjectifMax],
]) {
const table = evaluer(generee(petiteAObjectifs({ modeObjectifs: mode }), { graine: 3, nombre: 2, arret: 200 }));
const vue = tableau(table);
assert.deepEqual(duGroupe(vue), [premiere, 'toursHorsObjectif', 'toursSelonObjectif'], mode);
exigerCellulesAlignees(vue);
vue.lignes.forEach((ligne, rang) => assert.equal(cellule(ligne, premiere).valeur, lire(table.propositions[rang].mesures.objectifs), mode));
}
});
test('critère décoché, ou sans objet faute de personne qui corresponde : aucune colonne du groupe', () => {
const decoche = tableau(evaluer(generee(petiteAObjectifs({ respecterObjectifs: false, modeObjectifs: 'chacun' }), { graine: 3, nombre: 2, arret: 200 })));
assert.ok(sansSixieme(decoche), noms(decoche).join(', '));
assert.equal(duGroupe(decoche), null);
exigerCellulesAlignees(decoche);
const orpheline = petiteAObjectifs({ modeObjectifs: 'chacun' });
for (const participant of orpheline.participants) delete participant.etiquettes;
const table = evaluer(generee(orpheline, { graine: 3, nombre: 2, arret: 200 }));
assert.equal(table.criteres.find(({ nom }) => nom === 'toursHorsObjectif').sansObjet, true);
const vue = tableau(table);
assert.ok(sansSixieme(vue), noms(vue).join(', '));
assert.deepEqual(noms(vue), COLONNES_PAR_DEFAUT);
});
test('la case des animateurs cochée sur la grande : le minimum des animateurs rencontrés et les animateurs non rencontrés ; décochée, rien', () => {
const charge = structuredClone(chargeDemo('grande'));
charge.reglages.rencontrerAnimateurs = true;
const table = structuredClone(evaluer(generee(charge, { graine: 1, nombre: 1, arret: 2000 })));
// Trois minimums distincts : la colonne lit celui de toutes les
// personnes qui peuvent rencontrer un animateur, jamais celui d'une
// population.
const { animateurs } = table.propositions[0].mesures;
animateurs.agregat = { tous: { min: 0 }, mobiles: { min: 2 }, ancres: { min: 1 } };
const vue = tableau(table);
assert.deepEqual(duGroupe(vue), ['animateursRencontres', 'animateursNonRencontres']);
exigerCellulesAlignees(vue);
const [ligne] = vue.lignes;
assert.equal(cellule(ligne, 'animateursRencontres').valeur, 0);
assert.equal(cellule(ligne, 'animateursNonRencontres').valeur, animateurs.nonRencontres);
const sans = tableau(evaluer(generee(chargeDemo('grande'), { graine: 1, nombre: 1, arret: 2000 })));
assert.ok(sansSixieme(sans));
});
test('le mode se lit dans les valeurs de l’état des critères, jamais recalculé ; une mesure absente s’écrit « — » (A9)', () => {
const table = structuredClone(evaluer(generee(petiteAObjectifs(), { graine: 3, nombre: 2, arret: 200 })));
table.criteres.find(({ nom }) => nom === 'toursHorsObjectif').valeurs = ['horsObjectifMax', 'toursHorsObjectif'];
table.propositions[0].mesures.objectifs = null;
// Trois minimums distincts sur la seconde : la colonne lit celui de
// toutes les personnes concernées, jamais celui d'une population.
const servie = table.propositions[1];
servie.mesures.objectifs.agregat = { tous: { min: 0 }, mobiles: { min: 2 }, ancres: { min: 1 } };
const vue = tableau(table);
assert.equal(cellule(vue.lignes.find(({ id }) => id === servie.id), 'toursSelonObjectif').valeur, 0);
assert.deepEqual(duGroupe(vue), ['horsObjectifMax', 'toursHorsObjectif', 'toursSelonObjectif']);
for (const nom of ['horsObjectifMax', 'toursHorsObjectif', 'toursSelonObjectif']) {
const vide = cellule(vue.lignes[0], nom);
assert.deepEqual([vide.valeur, vide.cle], [null, 'format.vide'], nom);
assert.equal(texte(vide), '\u{2014}', nom);
assert.equal(typeof cellule(vue.lignes[1], nom).valeur, 'number', nom);
}
});
});
describe('tableau : la diversité en colonne (§ 5.7)', () => {
test('chaque ligne porte A(p), Σ r(p), d(p) et les paires distinctes ; sans affilié rencontré, d(p) s’écrit « — »', () => {
const vue = tableau(evaluer(generee(chargeDemo('petite'), { graine: 6, nombre: 2, arret: 300 })));
@ -646,7 +966,7 @@ describe('tableau : le retenu et les placements hors classement (§ 9, § 12.6)'
// imposés d'une ligne comparable.
const [comparable] = vue.lignes;
assert.equal(vue.etendueRaisons, comparable.cellules.length + 1);
assert.equal(vue.etendueRaisons, COLONNES.length - 3);
assert.equal(vue.etendueRaisons, vue.colonnes.length - 3);
});
test('une exclusion et un ajout, à effectif égal : hors classement aussi', () => {
@ -855,6 +1175,12 @@ describe('les explications des colonnes et des critères (infobulles, § 5.4, §
['effectifManque', 'bas'],
['ecartItineraireMax', 'bas'],
['ecartAuPlafondAPriori', 'bas'],
['jamaisServies', 'bas'],
['horsObjectifMax', 'bas'],
['toursHorsObjectif', 'bas'],
['toursSelonObjectif', 'grand'],
['animateursRencontres', 'grand'],
['animateursNonRencontres', 'bas'],
['certificat', null],
['imposes', null],
['commandes', null],
@ -868,7 +1194,7 @@ describe('les explications des colonnes et des critères (infobulles, § 5.4, §
const phrases = (texte) => texte.split(/(?<=\.)\s+(?=[\p{Lu}\u{00AB}])/u);
test('chaque colonne de COLONNES, sans exception, porte la clé de son explication : elle existe, compte une à trois phrases, et diffère de l’en-tête et des autres explications', () => {
assert.equal(COLONNES.length, 21);
assert.equal(COLONNES.length, 27);
assert.deepEqual(COLONNES.map(({ nom }) => nom), [...SENS.keys()]);
const textes = new Set();
for (const { nom, cle, explication } of COLONNES) {
@ -896,26 +1222,49 @@ describe('les explications des colonnes et des critères (infobulles, § 5.4, §
}
});
test('chaque critère, et l’identifiant croissant, partage l’explication de la colonne qu’il désigne ; seule l’explication d’un critère le dit critère du classement', () => {
const criteres = new Set(CRITERES.map(colonneDuCritere));
assert.equal(criteres.size, 5);
test('chaque valeur qui classe, et l’identifiant croissant, partage l’explication de la colonne qu’elle désigne ; seule l’explication d’une valeur qui classe la dit critère du classement', () => {
const valeurs = ['ecartAuPlafondAPrioriMax', 'excedentCollisions', 'collisionsCumulees', 'jamaisServies', 'horsObjectifMax']
.concat(['toursHorsObjectif', 'rencontresRepeteesChoisies', 'retoursChoisis', 'animateursNonRencontres', 'redondance']);
const criteres = new Set(valeurs.map(colonneDeValeur));
assert.equal(criteres.size, 10);
for (const { nom } of COLONNES) {
const texte = t(explicationDeColonne(nom));
if (criteres.has(nom)) assert.match(texte, /critère du classement/i, nom);
else assert.doesNotMatch(texte, /(?<!pas un )critère du classement/i, nom);
}
assert.match(t(explicationDeColonne(colonneDuCritere('identifiant'))), /départage/, 'identifiant');
assert.match(t(explicationDeColonne(colonneDeValeur('identifiant'))), /départage/, 'identifiant');
for (const options of [{}, { plafondsAPriori: null }]) {
const vue = tableau(evaluer(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT), options));
const designants = vue.ordre.flat().filter(({ colonne }) => colonne !== null);
assert.ok(designants.length >= 5);
for (const { critere, colonne } of designants) {
assert.equal(explicationDeColonne(colonne), explicationDeColonne(colonneDuCritere(critere)), critere);
assert.ok(t(explicationDeColonne(colonne)).length > 0, critere);
for (const { valeur, colonne } of designants) {
assert.equal(explicationDeColonne(colonne), explicationDeColonne(colonneDeValeur(valeur)), valeur);
assert.ok(t(explicationDeColonne(colonne)).length > 0, valeur);
}
}
});
test('l’explication d’une colonne que désigne une valeur qui classe ne fixe aucun rang : la place vient de l’ordre écrit au-dessus du tableau', () => {
const valeurs = new Set();
for (const mode of MODES_OBJECTIFS) {
for (const nouveauxVoisins of [true, false]) {
for (const nom of REGISTRE) for (const valeur of valeursQuiClassent(nom, { nouveauxVoisins }, mode)) valeurs.add(valeur);
}
}
assert.equal(valeurs.size, 10);
for (const valeur of valeurs) {
const texte = t(explicationDeColonne(colonneDeValeur(valeur)));
assert.doesNotMatch(texte, /critère du classement (?:après|avant)|(?:premier|dernier) critère/i, valeur);
assert.match(texte, /l'ordre écrit au-dessus du tableau/, valeur);
}
});
test('l’excédent de collisions dit son rang par défaut et pourquoi : passer la soirée avec le même collègue est pire que croiser six collègues une fois (§ 5.4)', () => {
const texte = t(explicationDeColonne('excedentCollisions'));
assert.match(texte, /par défaut avant les collisions cumulées/);
assert.match(texte, /le même collègue est pire que croiser six collègues une fois/);
});
test('explicationDeColonne lève pour un nom qui n’est pas une colonne', () => {
assert.equal(explicationDeColonne('rang'), 'propositions.explication.rang');
assert.throws(() => explicationDeColonne('redondance'), TypeError);
@ -951,7 +1300,7 @@ describe('GROUPES_COLONNES : les en-têtes groupés du tableau', () => {
assert.deepEqual(seules.map(({ colonnes }) => colonnes), [['rang'], ['proposition'], ['certificat'], ['imposes'], ['commandes']]);
});
test('cinq groupes de mesures voisines, chacun sous une clé « propositions.groupe. » qui a son texte, distinct des en-têtes de colonne', () => {
test('six groupes de mesures voisines, chacun sous une clé « propositions.groupe. » qui a son texte, distinct des en-têtes de colonne', () => {
const groupes = GROUPES_COLONNES.filter(({ cle }) => cle !== null);
assert.deepEqual(
groupes.map(({ colonnes }) => colonnes),
@ -961,8 +1310,10 @@ describe('GROUPES_COLONNES : les en-têtes groupés du tableau', () => {
['rencontresRepetees', 'maxRencontresPaire', 'retoursChoisis'],
['appartenancesVues', 'totalRedondance', 'diversite'],
['manqueMax', 'effectifManque', 'ecartItineraireMax', 'ecartAuPlafondAPriori'],
SIXIEME,
],
);
assert.equal(groupes.at(-1).cle, 'propositions.groupe.objectifs');
const enTetes = new Set(COLONNES.map(({ cle }) => t(cle)));
for (const { cle } of groupes) {
assert.match(cle, /^propositions\.groupe\.[a-z]+$/u);

View file

@ -0,0 +1,48 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Le sélecteur d'une règle d'objectifs (§ 4.5, D3), que partagent la
// règle par défaut (RegleObjectifs) et celle de chaque objectif
// (ListeTables) : un <select> et son style. Le porteur pose l'étiquette et
// le refus. options est [{valeur, libelle}], le libellé déjà traduit ;
// champ nomme sa désignation data-champ. idRefus est l'identifiant du
// refus affiché, null sans refus : il pose aria-invalid et le lien
// aria-describedby vers le message. surChoix reçoit l'événement change,
// dont currentTarget est le sélecteur.
let { id, champ, valeur, options, idRefus = null, surChoix } = $props();
</script>
<select
{id}
data-champ={champ}
value={valeur}
aria-invalid={idRefus === null ? undefined : 'true'}
aria-describedby={idRefus ?? undefined}
onchange={surChoix}
>
{#each options as option (option.valeur)}
<option value={option.valeur}>{option.libelle}</option>
{/each}
</select>
<style>
select {
padding: var(--espace-1) var(--espace-2);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-2);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
font: inherit;
font-size: var(--taille-texte);
}
select:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 1px;
}
select[aria-invalid='true'] {
border-color: var(--couleur-refus);
}
</style>

View file

@ -20,13 +20,40 @@
// page. Une frappe dans le champ, ou Échap, retire le
// complément d'un refus avec le refus que le champ retire.
//
// Supprimer une table qui porte une réservation ou un titre ouvre la
// fenêtre à confirmer, qui les nomme (bilanSuppressionTable) ; une table
// nue part sans fenêtre.
import { getContext } from 'svelte';
// Supprimer une table qui porte une réservation, un titre ou un objectif
// ouvre la fenêtre à confirmer, qui les nomme (bilanSuppressionTable) ;
// une table nue part sans fenêtre.
//
// Les objectifs d'une table (§ 4.5, D3, D19) se posent sous sa ligne :
// l'objectif de la soirée, puis le bouton « Objectifs par tour », qui
// déplie un objectif par tour et compte ceux qui existent (objectifs.js).
// Chaque objectif a son champ d'étiquettes, que le datalist des étiquettes
// connues complète, et le choix de sa règle (ChoixRegle) — le réglage
// général, « au moins une (OU) », « toutes (ET) ». Une saisie validée
// part à poserObjectif sous la règle choisie : celle de l'objectif posé,
// ou, pour un objectif neuf, celle que son choix tient sans rien écrire,
// le réglage général à défaut ; un seul geste, une seule entrée. Vider le
// champ d'un objectif posé est refusé contre lui, avec le remède
// « Retirer l'objectif ». Un objectif posé montre en plus son état en
// mots, « suit la règle par défaut » ou « règle propre », et « Retirer
// l'objectif », sans fenêtre : un retour à l'instant précédent le rend, et
// le focus passe à son champ. Ses avis — personne ne lui correspond, une
// étiquette que personne ne porte — se lisent contre lui, tant qu'ils
// tiennent ; une orthographe retenue au dernier geste aussi, tant que sa
// révision est la courante. Un refus de la séance se pose contre le
// champ, par Champ, ou contre le choix de la règle, qui le désigne par
// aria-describedby et montre alors la règle de la charge, ou le choix
// tenu d'un objectif neuf.
//
// « Exporter les objectifs » (§ 10.4) remet les octets d'exporterObjectifs
// à facade.enregistrerSous, sous le nom ‹base›-objectifs.csv
// (nomExportObjectifs), comme l'export des participants : sortir n'est pas
// modifier, elle part en lecture et sur un plan bloqué.
import { getContext, tick } from 'svelte';
import { on } from 'svelte/events';
import { bilanSuppressionTable } from '../../application/bilans.js';
import { lireEntier } from '../../application/capacite.js';
import { exporterObjectifs } from '../../application/csv.js';
import { ErreurCommande } from '../../application/erreurs.js';
import Carte from '../primitives/Carte.svelte';
import Champ from '../primitives/Champ.svelte';
@ -34,7 +61,18 @@
import Confirmation from '../primitives/Confirmation.svelte';
import Icone from '../primitives/Icone.svelte';
import Pastille from '../primitives/Pastille.svelte';
import Refus from '../primitives/Refus.svelte';
import { effetDe } from '../raccourcis.js';
import ChoixRegle from './ChoixRegle.svelte';
import {
basculerTable,
etiquettesConnues,
nomExportObjectifs,
objectifsDeLaTable,
optionsRegle,
regleChoisie,
regleParDefaut,
} from './objectifs.js';
import {
annonceSuppression,
avertissementsNommes,
@ -51,9 +89,27 @@
const t = getContext('t');
const seance = getContext('seance');
const facade = getContext('facade');
const uid = $props.id();
const idConnues = `${uid}-etiquettes`;
const tables = $derived(tablesParNumero(charge));
const comptes = $derived(comptesTables(charge));
const connues = $derived(etiquettesConnues(charge));
const options = $derived(optionsRegle(regleParDefaut(charge)).map(({ valeur, cle, details }) => ({ valeur, libelle: t(cle, details) })));
// Les tables dont les objectifs par tour sont dépliés ; par clé
// d'objectif, les orthographes retenues de son dernier geste
// { revision, avertissements }, le refus de son choix de règle, et, pour
// un objectif neuf, la valeur que son choix tient jusqu'à la saisie.
let ouvertes = $state.raw(new Set());
let retenues = $state({});
let refusRegle = $state({});
let reglesNeuves = $state({});
// La valeur du choix de règle d'un objectif : la sienne quand il existe,
// celle que tient le choix d'un objectif neuf sinon, vide à défaut.
const valeurRegle = (vue) => (vue.existe ? vue.regle : (reglesNeuves[vue.cle] ?? ''));
// Le complément de chaque ligne, par identifiant de table :
// { revision, retires, avertissements }. La fenêtre de suppression
@ -114,6 +170,77 @@
suppression = null;
}
// Les orthographes retenues du dernier geste sur l'objectif de clé donnée,
// tant que sa révision est la courante.
const retenuesDe = (cle) => {
const note = retenues[cle];
return note !== undefined && note.revision === revision ? note.avertissements : [];
};
// Pose l'objectif de la table au tour de vue : la saisie texte, sous la
// règle donnée, par défaut celle de son choix (valeurRegle). Posé, il
// porte sa règle : le choix tenu d'un objectif neuf s'oublie.
async function poser(table, vue, texte, regle = regleChoisie(valeurRegle(vue))) {
delete retenues[vue.cle];
const rendu = await seance.executer('poserObjectif', { table: table.id, tour: vue.tour, etiquettes: texte, regle });
delete reglesNeuves[vue.cle];
retenues[vue.cle] = {
revision: seance.etat().evenement.revision,
avertissements: rendu.avertissements.filter(({ code }) => code === 'ORTHOGRAPHE_RETENUE'),
};
}
// Le choix d'une règle : l'objectif posé se repose, mêmes étiquettes,
// sous elle, et le sélecteur montre ensuite la règle de la charge, écrite
// ou non. Pour un objectif neuf, le choix est tenu sans rien écrire ; le
// refus courant de la séance le refuse aussitôt, comme la première
// frappe d'un champ, et le choix rend sa valeur précédente.
async function choisirRegle(evenement, table, vue) {
const choix = evenement.currentTarget;
delete refusRegle[vue.cle];
if (!vue.existe) {
const refus = seance.etat().refus;
if (refus === null) reglesNeuves[vue.cle] = choix.value;
else {
refusRegle[vue.cle] = { code: refus.code, details: refus.details ?? {} };
choix.value = valeurRegle(vue);
}
return;
}
try {
await poser(table, vue, vue.texte, regleChoisie(choix.value));
} catch (erreur) {
if (typeof erreur?.code !== 'string') throw erreur;
refusRegle[vue.cle] = { code: erreur.code, details: erreur.details ?? {} };
} finally {
const courante = seance.etat().charge?.tables.find(({ id }) => id === table.id);
choix.value = courante?.objectifs?.find((objectif) => objectif.tour === vue.tour)?.regle ?? '';
}
}
// Retire l'objectif de vue ; son retrait ôte le bouton cliqué, et le
// focus passe au champ de l'objectif, qui reste.
async function retirer(table, vue) {
await seance.executer('retirerObjectif', { table: table.id, tour: vue.tour });
await tick();
document.getElementById(`${uid}-objectif-${vue.cle}`)?.querySelector('input')?.focus();
}
async function exporterLesObjectifs() {
const { evenement, charge: courante } = seance.etat();
await facade.enregistrerSous(nomExportObjectifs(evenement.base), exporterObjectifs(courante.tables));
}
// Le champ d'un objectif propose les étiquettes connues : son entrée
// porte la liste du datalist de la carte.
const avecConnues = (element) => {
for (const entree of element.querySelectorAll('input')) entree.setAttribute('list', idConnues);
};
// La saisie d'étiquettes se lit telle quelle : poserObjectif la découpe,
// la met en forme et la refuse.
const lireTexte = (texte) => ({ valeur: texte });
// Une frappe ou Échap dans le champ de la table id retire le complément
// de son refus : le champ retire le refus au même instant.
const suivreSaisie = (id) => (element) => {
@ -129,12 +256,76 @@
};
</script>
<!-- Un objectif d'une table, celui de la soirée ou d'un tour (vue). Sa
portée se pose en littéral, une branche par valeur : la garde des
désignations en tient la liste fermée. -->
{#snippet objectif(table, vue)}
{#if vue.tour === null}
<div class="objectif" id={`${uid}-objectif-${vue.cle}`} data-objectif={vue.cle} data-portee="tous">
{@render contenuObjectif(table, vue)}
</div>
{:else}
<div class="objectif" id={`${uid}-objectif-${vue.cle}`} data-objectif={vue.cle} data-portee="tour">
{@render contenuObjectif(table, vue)}
</div>
{/if}
{/snippet}
{#snippet contenuObjectif(table, vue)}
{@const refusDuChoix = refusRegle[vue.cle] ?? null}
{@const idRegle = `${uid}-regle-${vue.cle}`}
{@const idRefusRegle = `${idRegle}-refus`}
<div class="ligne-objectif">
<div class="saisie-objectif" {@attach avecConnues}>
<Champ
cle={`objectif-${vue.cle}`}
etiquette={vue.tour === null ? 'tables.objectif.soiree' : 'tables.objectif.tour'}
details={{ tour: vue.tour }}
valeur={vue.texte}
lire={lireTexte}
executer={(texte) => poser(table, vue, texte)}
/>
</div>
<div class="regle-objectif">
<label for={idRegle}>{t('tables.objectif.regle')}</label>
<ChoixRegle
id={idRegle}
champ={`regle-${vue.cle}`}
valeur={valeurRegle(vue)}
{options}
idRefus={refusDuChoix === null ? null : idRefusRegle}
surChoix={(evenement) => choisirRegle(evenement, table, vue)}
/>
</div>
{#if vue.existe}
<span class="etat-regle"><Pastille sorte="statut" valeur={vue.etat.ton} libelle={t(vue.etat.cle)} /></span>
<Commande cle="tables.objectif.retirer" nom="retirerObjectif" executer={() => retirer(table, vue)} />
{/if}
</div>
{#if refusDuChoix !== null}
<Refus id={idRefusRegle} refus={refusDuChoix} lever={() => delete refusRegle[vue.cle]} />
{/if}
{#each [...retenuesDe(vue.cle), ...vue.avis] as avis, rang (rang)}
<div class="message avis" data-avertissement={avis.code} role="status">
<span class="signe"><Icone nom="triangle" /></span>
<div class="texte"><p>{t(avis.code, avis.details)}</p></div>
</div>
{/each}
{/snippet}
<Carte titre={t('tables.liste.titre')} sousTitre={t('tables.liste.comptes', comptes)} niveau={3} plein>
{#snippet actions()}
<Commande cle="tables.creer.ronde" nom="creerTableRonde" executer={() => seance.executer('creerTable', { forme: 'ronde' })} />
<Commande cle="tables.creer.carree" nom="creerTableCarree" executer={() => seance.executer('creerTable', { forme: 'carree' })} />
<Commande cle="tables.objectifs.exporter" nom="exporterObjectifs" executer={exporterLesObjectifs} />
{/snippet}
<datalist id={idConnues}>
{#each connues as motCle (motCle)}
<option value={motCle}></option>
{/each}
</datalist>
{#if tables.length === 0}
<p class="aucune">{t('tables.liste.aucune')}</p>
{:else}
@ -142,6 +333,9 @@
{#each tables as table (table.id)}
{@const etat = etatTable(table, charge.evenement.siegesParDefaut)}
{@const complement = complementDe(table.id)}
{@const vues = objectifsDeLaTable(charge, table)}
{@const ouverte = ouvertes.has(table.id)}
{@const idTours = `${uid}-tours-${table.id}`}
<li data-table={table.id}>
<div class="entete">
<span class="numero"><span>{t('tables.liste.table')}</span> <span>{table.numero}</span></span>
@ -194,6 +388,27 @@
</div>
{/each}
{/if}
<div class="objectifs">
{@render objectif(table, vues.soiree)}
<button
type="button"
class="deplier"
data-commande="basculerObjectifsTours"
aria-expanded={ouverte ? 'true' : 'false'}
aria-controls={ouverte ? idTours : undefined}
onclick={() => (ouvertes = basculerTable(ouvertes, table.id))}
>
<span class="chevron"><Icone nom="chevron" /></span>
<span>{t('tables.objectifs.parTour', { nombre: vues.parTour })}</span>
</button>
{#if ouverte}
<ul class="par-tour" id={idTours}>
{#each vues.tours as vue (vue.cle)}
<li>{@render objectif(table, vue)}</li>
{/each}
</ul>
{/if}
</div>
</li>
{/each}
</ul>
@ -319,4 +534,82 @@
.texte p {
margin: 0;
}
/* Les objectifs d'une table : sous sa ligne, sur toute la largeur ; un
objectif par rangée, son champ, son choix de règle, son état et son
retrait côte à côte tant qu'elle les tient, ses messages dessous. */
.objectifs {
display: flex;
flex-basis: 100%;
flex-direction: column;
align-items: stretch;
gap: var(--espace-2);
}
.objectif {
display: flex;
flex-direction: column;
gap: var(--espace-2);
}
.ligne-objectif {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--espace-2) var(--espace-4);
}
.regle-objectif {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-1);
}
.etat-regle {
display: flex;
align-self: center;
min-width: 0;
}
/* Le bouton qui déplie les objectifs par tour : un texte de l'encre de la
page, sans fond, et son chevron, tourné vers le bas quand la liste est
dépliée. */
.deplier {
display: inline-flex;
align-self: flex-start;
align-items: center;
gap: var(--espace-1);
min-height: var(--hauteur-cible);
padding: 0 var(--espace-2);
border: 1px solid transparent;
border-radius: var(--rayon-2);
background: none;
color: var(--couleur-page-texte);
font: inherit;
font-size: var(--taille-texte);
cursor: pointer;
}
.deplier:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 1px;
}
.chevron {
display: inline-flex;
}
.deplier[aria-expanded='true'] .chevron {
transform: rotate(90deg);
}
.par-tour {
display: flex;
flex-direction: column;
gap: var(--espace-3);
margin: 0;
padding: 0 0 0 var(--espace-5);
list-style: none;
}
</style>

View file

@ -0,0 +1,67 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La règle par défaut des objectifs de table (§ 2.1 étape 4, § 4.5, D3) :
// un sélecteur, à côté des places par défaut et du nombre de tours, entre
// « au moins une (OU) » et « toutes (ET) ». Un choix exécute
// changerRegleObjectifs {regle}, une entrée ; les objectifs qui portent
// une règle propre n'en changent pas. À l'issue du geste, le sélecteur
// montre la règle de la charge, écrite ou inchangée. Un refus de la séance
// — lecture, plan bloqué — se pose à côté du sélecteur, avec son remède,
// et rien ne s'écrit.
import { getContext } from 'svelte';
import Refus from '../primitives/Refus.svelte';
import ChoixRegle from './ChoixRegle.svelte';
import { OPTIONS_REGLE_PAR_DEFAUT, regleParDefaut } from './objectifs.js';
let { charge } = $props();
const t = getContext('t');
const seance = getContext('seance');
const uid = $props.id();
const idChoix = `${uid}-regle`;
const idRefus = `${uid}-regle-refus`;
// Le refus du dernier choix, posé à côté du sélecteur.
let refus = $state.raw(null);
const options = $derived(OPTIONS_REGLE_PAR_DEFAUT.map(({ valeur, cle }) => ({ valeur, libelle: t(cle) })));
async function choisir(evenement) {
const choix = evenement.currentTarget;
refus = null;
try {
await seance.executer('changerRegleObjectifs', { regle: choix.value });
} catch (erreur) {
if (typeof erreur?.code !== 'string') throw erreur;
refus = { code: erreur.code, details: erreur.details ?? {} };
} finally {
choix.value = regleParDefaut(seance.etat().charge ?? charge);
}
}
</script>
<div class="regle" data-regle-objectifs>
<label for={idChoix}>{t('tables.regle.etiquette')}</label>
<ChoixRegle
id={idChoix}
champ="regleObjectifs"
valeur={regleParDefaut(charge)}
{options}
idRefus={refus === null ? null : idRefus}
surChoix={choisir}
/>
{#if refus !== null}
<Refus id={idRefus} {refus} lever={() => (refus = null)} />
{/if}
</div>
<style>
.regle {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-1);
}
</style>

View file

@ -2,9 +2,10 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La section des tables et des tours (§ 4, § 6, § 2.1 étapes 4 et 5) : la
// carte des réglages — le nombre de places par défaut et l'énoncé de son
// changement, le nombre de tours —, puis la carte de la liste des tables.
// La section des tables et des tours (§ 4, § 4.5, § 6, § 2.1 étapes 4 et
// 5) : la carte des réglages — le nombre de places par défaut et l'énoncé
// de son changement, le nombre de tours, la règle par défaut des
// objectifs —, puis la carte de la liste des tables et de leurs objectifs.
// Elle lit la charge et la révision dans l'état observable de la séance
// et les donne à ses parties ; chaque geste passe par la séance, et
// chaque refus se pose là où il a eu lieu.
@ -12,6 +13,7 @@
import Carte from '../primitives/Carte.svelte';
import Defaut from './Defaut.svelte';
import ListeTables from './ListeTables.svelte';
import RegleObjectifs from './RegleObjectifs.svelte';
import Tours from './Tours.svelte';
const t = getContext('t');
@ -26,6 +28,7 @@
<div class="reglages">
<Defaut charge={$seance.charge} revision={$seance.evenement.revision} />
<Tours charge={$seance.charge} />
<RegleObjectifs charge={$seance.charge} />
</div>
</Carte>
<ListeTables charge={$seance.charge} revision={$seance.evenement.revision} />

View file

@ -18,6 +18,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import '../jetons.css';
import { controlerCapacite } from '../../application/capacite.js';
import { exporterObjectifs } from '../../application/csv.js';
import { creerHorlogePilotee } from '../../application/horloge.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { nomAffiche } from '../../application/personnes.js';
@ -121,8 +122,16 @@ afterEach(() => {
// Le fichier d'état de la charge posé dans le système simulé, ouvert par une
// séance, en écriture sauf mode 'lecture' ; la section montée au début du
// corps, avec les contextes de l'application.
// corps, avec les contextes de l'application. La façade ne sait
// qu'enregistrer : chaque appel d'enregistrerSous se range dans enregistres.
async function preparer(charge, { mode = 'ecriture' } = {}) {
const enregistres = [];
const facade = {
async enregistrerSous(nomPropose, octets) {
enregistres.push({ nomPropose, octets });
return nomPropose;
},
};
const fs = creerFichiersSimules();
fs.deposerDossier('documents');
fs.deposer('documents', `${BASE}${SUFFIXES.etat}`, serialiser(charge, { revision: 1, produitVersion: VERSION.affichee }));
@ -149,13 +158,14 @@ async function preparer(charge, { mode = 'ecriture' } = {}) {
['t', t],
['horloge', horloge],
['ui', ui],
['facade', facade],
]);
const cible = document.createElement('div');
document.body.prepend(cible);
const composant = mount(SectionTables, { target: cible, props: {}, context: contexte });
flushSync();
montes.push({ composant, cible });
return { fs, seance, ui, cible };
return { fs, seance, ui, cible, enregistres };
}
const section = (m) => m.cible.querySelector('[data-section="tables"]');
@ -794,3 +804,302 @@ describe('l’habillage : cartes, pastilles, messages', () => {
expect(composantes(getComputedStyle(avis.querySelector('p')).color)).toEqual(jeton('--couleur-page-texte'));
});
});
// --- Les objectifs des tables (§ 4.5, § 10.4, D3, D19) ---------------------------
// L'objectif d'une table à l'écran, par sa clé : ‹table› pour la soirée,
// ‹table›-‹tour› pour un tour ; son champ d'étiquettes et son choix de règle.
const zoneObjectif = (m, cle) => section(m).querySelector(`[data-objectif="${cle}"]`);
const champObjectif = (m, cle) => zoneObjectif(m, cle).querySelector(`input[data-champ="objectif-${cle}"]`);
const choixRegle = (m, cle) => zoneObjectif(m, cle).querySelector(`select[data-champ="regle-${cle}"]`);
const regleDefaut = (m) => section(m).querySelector('[data-regle-objectifs] select[data-champ="regleObjectifs"]');
const objectifs = (m, id) => charge(m).tables.find((table) => table.id === id).objectifs;
// Le texte d'un objectif, sans les blancs du balisage.
const texteObjectif = (m, cle) => zoneObjectif(m, cle).textContent.replace(/\s+/g, ' ').trim();
// Choisit au clavier la valeur d'un sélecteur, un cran de flèche à la fois,
// la file de la séance passée à chaque cran : chaque cran est un choix. Un
// choix refusé rend la valeur enregistrée : au plus autant de crans que
// d'options.
async function choisirAuClavier(m, choix, valeur) {
choix.focus();
const cible = [...choix.options].findIndex((option) => option.value === valeur);
expect(cible, `option ${valeur}`).toBeGreaterThanOrEqual(0);
for (let cran = 0; cran < choix.options.length && choix.selectedIndex !== cible; cran += 1) {
await touche(choix.selectedIndex < cible ? 'ArrowDown' : 'ArrowUp');
await attendre(m);
}
}
// Deux tables, trois tours, quatre personnes : Iris Ombrelle porte
// « finance », Théo Pervenche « cuisine ».
function chargeObjectifs(options = {}) {
const donnees = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 4, tours: 3, ...options });
donnees.participants[0].etiquettes = ['finance'];
donnees.participants[1].etiquettes = ['cuisine'];
return donnees;
}
describe('la règle par défaut des objectifs (§ 2.1 étape 4, § 4.5, D3)', () => {
test('dans la carte des réglages, « au moins une (OU) » ; « toutes (ET) » choisie au clavier : une entrée, la règle propre d’un objectif ne change pas', async () => {
const donnees = chargeObjectifs();
donnees.tables[0].objectifs = [{ portee: 'tous', tour: null, regle: 'ou', etiquettes: ['finance'] }];
const m = await preparer(donnees);
const [reglages] = section(m).querySelectorAll('section[aria-labelledby]');
expect(reglages.contains(regleDefaut(m))).toBe(true);
expect(regleDefaut(m).value).toBe('ou');
expect(regleDefaut(m).selectedOptions[0].textContent).toBe(t('tables.regle.ou'));
expect(section(m).querySelector(`label[for="${regleDefaut(m).id}"]`).textContent).toBe(t('tables.regle.etiquette'));
const r0 = revision(m);
await choisirAuClavier(m, regleDefaut(m), 'et');
expect(revision(m)).toBe(r0 + 1);
expect(charge(m).reglages.regleObjectifs).toBe('et');
expect(regleDefaut(m).value).toBe('et');
expect(objectifs(m, 1)[0].regle).toBe('ou');
expect(choixRegle(m, '1').value).toBe('ou');
expect(choixRegle(m, '1').options[0].textContent).toBe(t('tables.objectif.regleDefaut', { regle: 'et' }));
});
});
describe('l’objectif d’une table (§ 4.5, D3, D19)', () => {
test('saisi « finance, cuisine » : une entrée, la règle suit le défaut et le dit ; « au moins une (OU) » choisie sous le défaut « ou » : l’état dit « règle propre », jamais celui du défaut', async () => {
const m = await preparer(chargeObjectifs());
expect(zoneObjectif(m, '1').dataset.portee).toBe('tous');
expect(choixRegle(m, '1').value).toBe('');
expect(commande(zoneObjectif(m, '1'), 'retirerObjectif')).toBeNull();
const r0 = revision(m);
await saisir(m, champObjectif(m, '1'), 'finance, cuisine');
expect(revision(m)).toBe(r0 + 1);
expect(objectifs(m, 1)).toEqual([{ portee: 'tous', tour: null, regle: null, etiquettes: ['cuisine', 'finance'] }]);
expect(champObjectif(m, '1').value).toBe('cuisine, finance');
expect(choixRegle(m, '1').value).toBe('');
const suit = texteObjectif(m, '1');
expect(suit).toContain(t('tables.objectif.suitDefaut'));
expect(suit).not.toContain(t('tables.objectif.reglePropre'));
await choisirAuClavier(m, choixRegle(m, '1'), 'ou');
expect(revision(m)).toBe(r0 + 2);
expect(objectifs(m, 1)[0].regle).toBe('ou');
expect(charge(m).reglages.regleObjectifs).toBeUndefined();
const propre = texteObjectif(m, '1');
expect(propre).toContain(t('tables.objectif.reglePropre'));
expect(propre).not.toContain(t('tables.objectif.suitDefaut'));
});
test('un objectif neuf : sa règle se choisit avant ses étiquettes, rien d’écrit ; la saisie le pose sous elle, une seule entrée', async () => {
const m = await preparer(chargeObjectifs());
expect(section(m).querySelector(`label[for="${choixRegle(m, '1').id}"]`).textContent).toBe(t('tables.objectif.regle'));
expect(texteObjectif(m, '1')).not.toContain(t('tables.objectif.suitDefaut'));
const r0 = revision(m);
await choisirAuClavier(m, choixRegle(m, '1'), 'et');
expect(revision(m)).toBe(r0);
expect(objectifs(m, 1)).toBeUndefined();
expect(choixRegle(m, '1').value).toBe('et');
expect(document.activeElement).toBe(choixRegle(m, '1'));
await saisir(m, champObjectif(m, '1'), 'finance, cuisine');
expect(revision(m)).toBe(r0 + 1);
expect(objectifs(m, 1)).toEqual([{ portee: 'tous', tour: null, regle: 'et', etiquettes: ['cuisine', 'finance'] }]);
expect(choixRegle(m, '1').value).toBe('et');
expect(texteObjectif(m, '1')).toContain(t('tables.objectif.reglePropre'));
// L'autre table garde son choix vide : le choix d'un objectif neuf est
// le sien.
expect(choixRegle(m, '2').value).toBe('');
});
test('les avis contre l’objectif : personne ne correspond, une étiquette que personne ne porte, sa voisine nommée ; le champ propose les étiquettes connues', async () => {
const m = await preparer(chargeObjectifs());
const liste = document.getElementById(champObjectif(m, '2').getAttribute('list'));
expect(liste.localName).toBe('datalist');
expect([...liste.options].map((option) => option.value)).toEqual(['cuisine', 'finance']);
await saisir(m, champObjectif(m, '2'), 'Finances');
const avis = [...zoneObjectif(m, '2').querySelectorAll('[data-avertissement]')].map((element) => element.dataset.avertissement);
expect(avis).toEqual(['OBJECTIF_SANS_CORRESPONDANT', 'ETIQUETTE_ORPHELINE']);
expect(texteObjectif(m, '2')).toContain(t('ETIQUETTE_ORPHELINE', { etiquette: 'Finances', voisine: null }));
expect([...liste.options].map((option) => option.value)).toEqual(['Finances', 'cuisine', 'finance']);
// Théo Pervenche visé : l'objectif sert quelqu'un, les avis tombent.
await saisir(m, champObjectif(m, '2'), 'cuisine');
expect(zoneObjectif(m, '2').querySelectorAll('[data-avertissement]')).toHaveLength(0);
// Un objectif écrit « Finance » à la main, quand Iris Ombrelle porte
// « finance » : l'orpheline nomme sa voisine de même clé.
const donnees = chargeObjectifs();
donnees.tables[1].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['Finance'] }];
const n = await preparer(donnees);
expect(texteObjectif(n, '2')).toContain(t('ETIQUETTE_ORPHELINE', { etiquette: 'Finance', voisine: 'finance' }));
});
test('une orthographe retenue se dit contre le champ : « FINANCE » saisi reçoit « finance »', async () => {
const m = await preparer(chargeObjectifs());
await saisir(m, champObjectif(m, '1'), 'FINANCE');
expect(objectifs(m, 1)[0].etiquettes).toEqual(['finance']);
expect(champObjectif(m, '1').value).toBe('finance');
const retenue = zoneObjectif(m, '1').querySelector('[data-avertissement="ORTHOGRAPHE_RETENUE"]');
expect(retenue).not.toBeNull();
expect(retenue.textContent).toContain(t('ORTHOGRAPHE_RETENUE', { saisie: 'FINANCE', retenue: 'finance' }));
});
test('« Retirer l’objectif » : sans fenêtre, une entrée, le champ se vide ; vider le champ d’un objectif posé est refusé contre lui, et son remède le retire', async () => {
const donnees = chargeObjectifs();
donnees.tables[0].objectifs = [{ portee: 'tous', tour: null, regle: 'et', etiquettes: ['finance'] }];
donnees.tables[1].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['cuisine'] }];
const m = await preparer(donnees);
const r0 = revision(m);
await cliquer(m, commande(zoneObjectif(m, '1'), 'retirerObjectif'));
expect(fenetre()).toBeNull();
expect(revision(m)).toBe(r0 + 1);
expect(objectifs(m, 1)).toBeUndefined();
expect(champObjectif(m, '1').value).toBe('');
expect(choixRegle(m, '1').value).toBe('');
expect(document.activeElement).toBe(champObjectif(m, '1'));
await saisir(m, champObjectif(m, '2'), '');
const refus = zoneObjectif(m, '2').querySelector('[data-refus="OBJECTIF_SANS_ETIQUETTE"]');
expect(refus).not.toBeNull();
expect(revision(m)).toBe(r0 + 1);
await cliquer(m, commande(refus, 'retirerObjectif'));
expect(fenetre()).toBeNull();
expect(revision(m)).toBe(r0 + 2);
expect(objectifs(m, 2)).toBeUndefined();
});
test('« Objectifs par tour » : replié, le bouton compte ceux qui existent ; déplié au clavier, un champ par tour ; posé au tour 2, une entrée, le bouton en compte deux ; replié à l’espace', async () => {
const donnees = chargeObjectifs();
donnees.tables[0].objectifs = [{ portee: 'tour', tour: 3, regle: null, etiquettes: ['finance'] }];
const m = await preparer(donnees);
const bouton = commande(ligne(m, 1), 'basculerObjectifsTours');
expect(bouton.getAttribute('aria-expanded')).toBe('false');
expect(bouton.textContent.trim()).toBe(t('tables.objectifs.parTour', { nombre: 1 }));
expect(commande(ligne(m, 2), 'basculerObjectifsTours').textContent.trim()).toBe(t('tables.objectifs.parTour', { nombre: 0 }));
expect(zoneObjectif(m, '1-1')).toBeNull();
bouton.focus();
await touche('Enter');
await attendre(m);
expect(bouton.getAttribute('aria-expanded')).toBe('true');
expect(document.getElementById(bouton.getAttribute('aria-controls')).contains(zoneObjectif(m, '1-1'))).toBe(true);
expect(['1-1', '1-2', '1-3'].map((cle) => zoneObjectif(m, cle).dataset.portee)).toEqual(['tour', 'tour', 'tour']);
expect(champObjectif(m, '1-3').value).toBe('finance');
expect(zoneObjectif(m, '2-1')).toBeNull();
const r0 = revision(m);
await saisir(m, champObjectif(m, '1-2'), 'cuisine');
expect(revision(m)).toBe(r0 + 1);
expect(objectifs(m, 1).map(({ tour }) => tour)).toEqual([2, 3]);
expect(bouton.textContent.trim()).toBe(t('tables.objectifs.parTour', { nombre: 2 }));
bouton.focus();
await touche(' ');
await attendre(m);
expect(bouton.getAttribute('aria-expanded')).toBe('false');
expect(zoneObjectif(m, '1-2')).toBeNull();
});
});
describe('« Exporter les objectifs » (§ 10.4)', () => {
// Une proposition retenue, puis le plan bloqué.
function chargeBloquee() {
const donnees = chargeObjectifs();
donnees.tables[0].objectifs = [
{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] },
{ portee: 'tour', tour: 2, regle: 'et', etiquettes: ['cuisine', 'finance'] },
];
placer(donnees, [[[1, 2], [3, 4]], [[1, 3], [2, 4]], [[1, 4], [2, 3]]], { id: 1, retenu: true });
donnees.evenement.etat = 'bloque';
return donnees;
}
test('en écriture, en lecture et sur un plan bloqué : les octets d’exporterObjectifs, sous ‹base›-objectifs.csv, rien d’écrit au fichier', async () => {
// Un plan bloqué ne passe pas en écriture : la séance le tient en
// lecture seule, et refuse tout geste qui modifie.
const ouverte = chargeObjectifs();
ouverte.tables[1].objectifs = [{ portee: 'tour', tour: 3, regle: null, etiquettes: ['cuisine'] }];
for (const [donnees, mode, refus] of [
[ouverte, 'ecriture', null],
[ouverte, 'lecture', 'MODE_LECTURE'],
[chargeBloquee(), 'lecture', 'LECTURE_SEULE'],
]) {
const m = await preparer(donnees, { mode });
expect(m.seance.etat().refus?.code ?? null).toBe(refus);
expect(charge(m).tables.some(({ objectifs: liste }) => liste !== undefined)).toBe(true);
const avant = ecritures(m);
const bouton = commande(section(m), 'exporterObjectifs');
expect(bouton.textContent).toBe(t('tables.objectifs.exporter'));
await cliquer(m, bouton);
await attendre(m);
expect(m.enregistres).toHaveLength(1);
expect(m.enregistres[0].nomPropose).toBe(`${BASE}-objectifs.csv`);
expect([...m.enregistres[0].octets]).toEqual([...exporterObjectifs(charge(m).tables)]);
expect(bouton.parentElement.querySelector('[data-refus]')).toBeNull();
expect(ecritures(m)).toBe(avant);
}
});
});
describe('les objectifs que les fenêtres nomment (§ 2.1 étape 5, § 4.3, § 6.2, A3, A6)', () => {
test('trois tours, un objectif au tour 3 et un de la soirée ; 2 saisi : la fenêtre nomme l’objectif du tour 3, rien d’écrit ; Confirmer : il part, la soirée reste', async () => {
const donnees = chargeObjectifs();
donnees.tables[1].objectifs = [
{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] },
{ portee: 'tour', tour: 3, regle: null, etiquettes: ['cuisine'] },
];
const m = await preparer(donnees);
const avant = ecritures(m);
await saisir(m, champ(m, 'tours'), '2');
expect(fenetre()).not.toBeNull();
expect(ecritures(m)).toBe(avant);
expect(elementsFenetre()).toEqual([t('tables.tours.objectif', { numero: 2, tour: 3, etiquettes: ['cuisine'] })]);
await cliquer(m, commande(fenetre(), 'confirmer'));
expect(fenetre()).toBeNull();
expect(objectifs(m, 2)).toEqual([{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }]);
});
test('une table qui ne porte qu’un objectif : sa suppression ouvre la fenêtre, qui le nomme', async () => {
const donnees = chargeObjectifs();
donnees.tables[0].objectifs = [{ portee: 'tour', tour: 2, regle: 'et', etiquettes: ['cuisine', 'finance'] }];
const m = await preparer(donnees);
await cliquer(m, commande(ligne(m, 1), 'supprimerTable'));
expect(fenetre()).not.toBeNull();
expect(elementsFenetre()).toEqual([t('tables.suppression.objectif', { tour: 2, etiquettes: ['cuisine', 'finance'] })]);
await cliquer(m, commande(fenetre(), 'renoncer'));
expect(charge(m).tables).toHaveLength(2);
});
});
describe('les objectifs en lecture (§ 8.4)', () => {
test('poser un objectif, choisir la règle par défaut ou celle d’un objectif, le retirer : refusés contre le geste, le message porte « Modifier », rien d’écrit ; le choix montre la règle enregistrée', async () => {
const donnees = chargeObjectifs();
donnees.tables[0].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }];
const m = await preparer(donnees, { mode: 'lecture' });
const avant = ecritures(m);
const modifier = t('communs.remede.modifier');
await cliquer(m, champObjectif(m, '2'));
await touche('x');
await attendre(m);
expect(zoneObjectif(m, '2').querySelector('[data-refus="MODE_LECTURE"]').textContent).toContain(modifier);
await choisirAuClavier(m, regleDefaut(m), 'et');
expect(regleDefaut(m).value).toBe('ou');
expect(regleDefaut(m).closest('[data-regle-objectifs]').querySelector('[data-refus="MODE_LECTURE"]').textContent).toContain(modifier);
const decrit = (choix) => document.getElementById(choix.getAttribute('aria-describedby'));
expect(decrit(regleDefaut(m)).dataset.refus).toBe('MODE_LECTURE');
await choisirAuClavier(m, choixRegle(m, '1'), 'ou');
expect(choixRegle(m, '1').value).toBe('');
expect(zoneObjectif(m, '1').querySelector('[data-refus="MODE_LECTURE"]').textContent).toContain(modifier);
expect(choixRegle(m, '1').getAttribute('aria-invalid')).toBe('true');
expect(decrit(choixRegle(m, '1')).dataset.refus).toBe('MODE_LECTURE');
expect(decrit(choixRegle(m, '1')).textContent).toContain(modifier);
expect(document.activeElement).toBe(choixRegle(m, '1'));
// Le choix d'un objectif que la table ne porte pas encore : refusé de
// même, il rend la valeur vide.
await choisirAuClavier(m, choixRegle(m, '2'), 'et');
expect(choixRegle(m, '2').value).toBe('');
expect(decrit(choixRegle(m, '2')).dataset.refus).toBe('MODE_LECTURE');
await cliquer(m, commande(zoneObjectif(m, '1'), 'retirerObjectif'));
expect(commande(zoneObjectif(m, '1'), 'retirerObjectif').parentElement.querySelector('[data-refus="MODE_LECTURE"]')).not.toBeNull();
expect(objectifs(m, 1)).toHaveLength(1);
expect(charge(m).reglages.regleObjectifs).toBeUndefined();
expect(ecritures(m)).toBe(avant);
});
});

View file

@ -8,9 +8,11 @@
// aussitôt quand rien ne se perd — une hausse, dont la dérive des
// placements s'avertit par la séance ; sinon la fenêtre à confirmer
// nomme, avant toute écriture, les réservations « tour désigné » qu'elle
// détruit — personne et table — et les placements qui perdent des tours
// (bilanTours, annonceTours). Annuler la ferme et remonte le champ, qui rend la valeur
// enregistrée ; Confirmer exécute le changement, une entrée.
// détruit — personne et table —, les objectifs de table des tours retirés
// — table, tour et étiquettes (§ 4.3, A3) — et les placements qui perdent
// des tours (bilanTours, annonceTours, elementsTours). Annuler la ferme et
// remonte le champ, qui rend la valeur enregistrée ; Confirmer exécute le
// changement, une entrée.
import { getContext } from 'svelte';
import { bilanTours } from '../../application/bilans.js';
import { lireEntier } from '../../application/capacite.js';

View file

@ -156,11 +156,12 @@ export function surchargesPerdues(charge) {
/**
* Vrai quand le changement de avant à apres tours s'annonce avant d'être
* appliqué (§ 2.1 étape 5) : il détruit une réservation « tour désigné »
* au-delà, ou, pour une baisse, il laisse en dérive un placement engendré,
* entier mais qui porte des tours que l'événement n'a plus. Une hausse ne
* détruit rien : les placements qu'elle laisse en dérive restent entiers,
* et la séance en avertit (DERIVE_NOUVELLE) sans fenêtre (§ 5.9).
* appliqué (§ 2.1 étape 5, § 4.3) : il détruit une réservation « tour
* désigné » ou un objectif de table d'un tour au-delà, ou, pour une baisse,
* il laisse en dérive un placement engendré, entier mais qui porte des
* tours que l'événement n'a plus. Une hausse ne détruit rien : les
* placements qu'elle laisse en dérive restent entiers, et la séance en
* avertit (DERIVE_NOUVELLE) sans fenêtre (§ 5.9).
*
* @param {ReturnType<import('../../application/bilans.js').bilanTours>} bilan
* @param {number} avant le nombre de tours enregistré
@ -169,6 +170,7 @@ export function surchargesPerdues(charge) {
*/
export const annonceTours = (bilan, avant, apres) =>
bilan.reservationsDetruites.length > 0 ||
bilan.objectifsDetruits.length > 0 ||
(apres < avant && (bilan.propositionsTouchees.length > 0 || bilan.retenuTouche));
// Les placements qu'un changement laisse en dérive : chaque proposition,
@ -178,14 +180,26 @@ const touches = (bilan) => [
...(bilan.retenuTouche ? [{ cle: 'tables.touchee.retenu', details: {} }] : []),
];
// Les étiquettes de l'objectif de la table id au tour donné : le bilan des
// tours ne nomme que la table et le tour d'un objectif détruit.
function etiquettesDe(charge, id, tour) {
const table = charge.tables.find((candidate) => candidate.id === id);
const objectif = table?.objectifs?.find((candidat) => candidat.tour === tour);
if (objectif === undefined) throw new TypeError(`objectif inconnu : table ${JSON.stringify(id)}, tour ${JSON.stringify(tour)}`);
return objectif.etiquettes;
}
/**
* Ce que nomme la fenêtre d'un changement de tours (§ 2.1 étape 5) : chaque
* réservation détruite, sa personne et le numéro de sa table, dans l'ordre
* du bilan, puis les placements touchés.
* Ce que nomme la fenêtre d'un changement de tours (§ 2.1 étape 5, A3) :
* chaque réservation détruite, sa personne et le numéro de sa table, puis
* chaque objectif de table détruit, le numéro de sa table, son tour et ses
* étiquettes, chacun dans l'ordre du bilan ; puis les placements touchés.
* Les objectifs de la soirée restent et ne se nomment pas.
*
* @param {Object} charge
* @param {ReturnType<import('../../application/bilans.js').bilanTours>} bilan
* @returns {Array<{cle: string, details: Object}>}
* @throws {TypeError} un objectif du bilan que la charge ne porte pas
*/
export function elementsTours(charge, bilan) {
return [
@ -193,18 +207,23 @@ export function elementsTours(charge, bilan) {
cle: 'tables.tours.reservation',
details: { nom: nomDe(charge, participant), numero: numeroDe(charge.tables, table), tour },
})),
...bilan.objectifsDetruits.map(({ table, tour }) => ({
cle: 'tables.tours.objectif',
details: { numero: numeroDe(charge.tables, table), tour, etiquettes: etiquettesDe(charge, table, tour) },
})),
...touches(bilan),
];
}
/** Vrai quand supprimer la table détruit une réservation ou un titre. */
export const annonceSuppression = (bilan) => bilan.reservations.length > 0 || bilan.titres.length > 0;
/** Vrai quand supprimer la table détruit une réservation, un titre ou un objectif. */
export const annonceSuppression = (bilan) => bilan.reservations.length > 0 || bilan.titres.length > 0 || bilan.objectifs.length > 0;
/**
* Ce que nomme la fenêtre de la suppression d'une table (§ 6.2) : ses
* Ce que nomme la fenêtre de la suppression d'une table (§ 6.2, A16) : ses
* réservations, leur personne, leur portée et leur siège, puis ses titres,
* dans l'ordre du bilan ; puis les placements touchés. Rien pour une table
* nue, que la suppression ne demande pas de confirmer.
* puis ses objectifs, leur tour — null pour la soirée — et leurs
* étiquettes, dans l'ordre du bilan ; puis les placements touchés. Rien
* pour une table nue, que la suppression ne demande pas de confirmer.
*
* @param {Object} charge
* @param {ReturnType<import('../../application/bilans.js').bilanSuppressionTable>} bilan
@ -218,6 +237,7 @@ export function elementsSuppression(charge, bilan) {
details: { nom: nomDe(charge, participant), portee, tour, siege },
})),
...bilan.titres.map(({ siege, libelle }) => ({ cle: 'tables.suppression.titre', details: { siege, libelle } })),
...bilan.objectifs.map(({ tour, etiquettes }) => ({ cle: 'tables.suppression.objectif', details: { tour, etiquettes } })),
...touches(bilan),
];
}

View file

@ -277,6 +277,52 @@ describe('ce que nomment les fenêtres (§ 6.1, § 6.2, § 2.1 étape 5)', () =>
});
});
// Un objectif de table (§ 4.5) : la soirée pour tour null, sinon le tour
// désigné ; regle null suit le défaut.
const objectif = (tour, etiquettes, regle = null) => ({ portee: tour === null ? 'tous' : 'tour', tour, regle, etiquettes });
describe('les objectifs perdus, nommés par les fenêtres (§ 2.1 étape 5, § 4.3, § 6.2, A3, A6, A16)', () => {
test('elementsTours : les réservations détruites, puis chaque objectif des tours retirés, sa table et ses étiquettes, puis les placements ; l’objectif de la soirée reste', () => {
const charge = chargeDe({ tables: [{ id: 1, numero: 1 }, { id: 2, numero: 5 }], n: 4, tours: 4, reservations: [resa(1, 2, null, 4)] });
charge.tables[0].objectifs = [objectif(null, ['finance']), objectif(3, ['Montréal', 'finance'], 'et'), objectif(4, ['bénévole'])];
charge.tables[1].objectifs = [objectif(4, ['cuisine'])];
charge.propositions = [{ id: 1, tours: 4 }];
const bilan = bilanTours(charge, 2);
assert.equal(annonceTours(bilan, 4, 2), true);
assert.deepEqual(
elementsTours(charge, bilan).map(({ cle, details }) => t(cle, details)),
[
'Iris Ombrelle : réservation à la table 5 au tour 4, détruite',
'Table 1 : objectif du tour 3 (« Montréal », « finance »), détruit',
'Table 1 : objectif du tour 4 (« bénévole »), détruit',
'Table 5 : objectif du tour 4 (« cuisine »), détruit',
t('tables.touchee.proposition', { id: 1 }),
],
);
});
test('annonceTours : un objectif de tour détruit s’annonce seul, sans réservation ni placement ; celui de la soirée, jamais', () => {
const charge = chargeDe({ tables: [{ id: 1 }], n: 2, tours: 3 });
charge.tables[0].objectifs = [objectif(null, ['finance'])];
assert.equal(annonceTours(bilanTours(charge, 1), 3, 1), false);
charge.tables[0].objectifs.push(objectif(3, ['finance']));
assert.equal(annonceTours(bilanTours(charge, 2), 3, 2), true);
assert.equal(annonceTours(bilanTours(charge, 3), 3, 3), false);
});
test('elementsSuppression : une table qui ne porte que des objectifs s’annonce, chacun nommé, la soirée d’abord, ses étiquettes séparées d’une virgule, sans « et » qui dirait une règle', () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 2, tours: 3 });
charge.tables[0].objectifs = [objectif(null, ['finance', 'ressources humaines']), objectif(2, ['bénévole'], 'et')];
const bilan = bilanSuppressionTable(charge, 1);
assert.equal(annonceSuppression(bilan), true);
assert.deepEqual(
elementsSuppression(charge, bilan).map(({ cle, details }) => t(cle, details)),
['Objectif de la soirée : « finance », « ressources humaines »', 'Objectif du tour 2 : « bénévole »'],
);
assert.equal(annonceSuppression(bilanSuppressionTable(charge, 2)), false);
});
});
describe('les compléments d’un refus et d’un avertissement (§ 6.2)', () => {
test('siegesRetiresNommes : le siège 8 et son titre, puis le siège 7 et Iris Ombrelle, dans cet ordre', () => {
const charge = chargeDe({

View file

@ -0,0 +1,157 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le modèle des objectifs de table à l'écran (§ 4.5, § 5.9, § 10.4, D3),
// pur : ListeTables et RegleObjectifs le câblent. Il lit la charge et rend,
// pour chaque objectif d'une table — la soirée, puis chaque tour —, ce que
// l'écran montre : ses étiquettes en texte, jointes comme l'export les
// joint, sa règle, l'état « suit le défaut » ou « règle propre », et ses
// avis, ceux d'avisDeLObjectif (src/application/objectifs.js), qui décide
// seul de la correspondance. Il rend des clés de libellé et leurs détails,
// jamais un texte. Aucune fonction ne modifie ce qu'elle reçoit.
import { JOINTURE_ETIQUETTES } from '../../application/csv.js';
import { avisDeLObjectif, etiquettesDeLEvenement, regleObjectifs } from '../../application/objectifs.js';
// Deux chaînes comparées unité UTF-16 par unité.
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
/**
* La règle par défaut des objectifs de l'événement (§ 4.5, D3) : celle des
* réglages, « ou » quand ils n'en portent pas. C'est regleObjectifs, la
* seule lecture de ce défaut, que les commandes et les avis lisent aussi.
*
* @param {{reglages: {regleObjectifs?: 'et'}}} charge
* @returns {'ou'|'et'}
*/
export const regleParDefaut = regleObjectifs;
/** Les deux règles que le défaut de l'événement peut prendre, chacune sa clé de libellé. */
export const OPTIONS_REGLE_PAR_DEFAUT = Object.freeze([
Object.freeze({ valeur: 'ou', cle: 'tables.regle.ou' }),
Object.freeze({ valeur: 'et', cle: 'tables.regle.et' }),
]);
/**
* Les choix de la règle d'un objectif (§ 4.5) : la valeur vide, qui suit le
* défaut de l'événement et le nomme, puis « au moins une (OU) » et
* « toutes (ET) », deux règles propres. Un choix est {valeur, cle, details}.
*
* @param {'ou'|'et'} regle la règle par défaut de l'événement
* @returns {Array<{valeur: ''|'ou'|'et', cle: string, details: Object}>}
*/
export function optionsRegle(regle) {
return [
{ valeur: '', cle: 'tables.objectif.regleDefaut', details: { regle } },
...OPTIONS_REGLE_PAR_DEFAUT.map(({ valeur, cle }) => ({ valeur, cle, details: {} })),
];
}
/**
* La règle que poserObjectif reçoit pour un choix : null pour la valeur
* vide, qui suit le défaut, la règle propre sinon.
*
* @param {string} valeur
* @returns {'ou'|'et'|null}
* @throws {RangeError} une valeur hors des choix d'optionsRegle
*/
export function regleChoisie(valeur) {
if (valeur === '') return null;
if (valeur === 'ou' || valeur === 'et') return valeur;
throw new RangeError(`règle inconnue : ${JSON.stringify(valeur)}`);
}
/**
* La clé d'un objectif à l'écran : l'identifiant de sa table pour la soirée,
* ‹table›-‹tour› pour un tour désigné. Elle nomme son champ, son choix de
* règle et sa désignation data-objectif.
*
* @param {number} table
* @param {number|null} tour
* @returns {string}
*/
export const cleObjectif = (table, tour) => (tour === null ? String(table) : `${table}-${tour}`);
/**
* Ce que l'écran montre de l'objectif de la table au tour donné, null pour
* la soirée. existe dit si la table le porte ; texte, ses étiquettes dans
* l'ordre du fichier, jointes par JOINTURE_ETIQUETTES, vide sans objectif ;
* regle, la valeur de son choix de règle — '' quand il suit le défaut ou
* n'existe pas ; etat, la clé et le ton de « suit le défaut » ou « règle
* propre » (D3), null sans objectif : une règle propre égale au défaut
* garde son état propre ; avis, ceux d'avisDeLObjectif.
*
* @param {Object} charge
* @param {{id: number, objectifs?: Array<{tour: number|null, regle: 'ou'|'et'|null, etiquettes: string[]}>}} table
* @param {number|null} tour
* @returns {{cle: string, tour: number|null, existe: boolean, texte: string,
* regle: ''|'ou'|'et', etat: {cle: string, ton: 'neutre'|'information'}|null,
* avis: Array<{code: string, details: Object}>}}
*/
export function vueObjectif(charge, table, tour) {
const objectif = (table.objectifs ?? []).find((candidat) => candidat.tour === tour);
const base = { cle: cleObjectif(table.id, tour), tour };
if (objectif === undefined) return { ...base, existe: false, texte: '', regle: '', etat: null, avis: [] };
const propre = objectif.regle !== null;
return {
...base,
existe: true,
texte: objectif.etiquettes.join(JOINTURE_ETIQUETTES),
regle: objectif.regle ?? '',
etat: propre
? { cle: 'tables.objectif.reglePropre', ton: 'information' }
: { cle: 'tables.objectif.suitDefaut', ton: 'neutre' },
avis: avisDeLObjectif(charge, table.id, tour),
};
}
/**
* Les objectifs d'une table à l'écran : celui de la soirée, puis un par tour
* de 1 à R, chacun par vueObjectif ; parTour, le nombre de tours qui en
* portent un, que dit le bouton qui déplie leur liste.
*
* @param {Object} charge
* @param {Object} table
* @returns {{soiree: ReturnType<typeof vueObjectif>, tours: Array<ReturnType<typeof vueObjectif>>, parTour: number}}
*/
export function objectifsDeLaTable(charge, table) {
const tours = Array.from({ length: charge.evenement.tours }, (_, rang) => vueObjectif(charge, table, rang + 1));
return {
soiree: vueObjectif(charge, table, null),
tours,
parTour: tours.filter(({ existe }) => existe).length,
};
}
/**
* Les étiquettes que la saisie d'un objectif propose (§ 4.5) : celles de
* l'événement, personnes et objectifs confondus, chacune une fois, triées
* par unités de code.
*
* @param {Object} charge
* @returns {string[]}
*/
export const etiquettesConnues = (charge) => [...new Set(etiquettesDeLEvenement(charge))].sort(comparerTextes);
/**
* Le nom proposé à l'enregistrement de l'export des objectifs (§ 10.4) :
* ‹base›-objectifs.csv, sur le patron de celui des participants.
*
* @param {string} base
* @returns {string}
*/
export const nomExportObjectifs = (base) => `${base}-objectifs.csv`;
/**
* Les tables dont les objectifs par tour sont dépliés, après un clic sur
* celui de la table id : elle s'y ajoute, ou s'en retire. Un ensemble neuf.
*
* @param {Set<number>} ouvertes
* @param {number} id
* @returns {Set<number>}
*/
export function basculerTable(ouvertes, id) {
const suivantes = new Set(ouvertes);
if (suivantes.has(id)) suivantes.delete(id);
else suivantes.add(id);
return suivantes;
}

View file

@ -0,0 +1,194 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du modèle des objectifs de table (§ 4.5, § 5.9, § 10.4, D3, D19) :
// ce que chaque objectif montre — ses étiquettes en texte, sa règle, l'état
// « suit le défaut » ou « règle propre » écrit en mots, ses avis —, les
// choix de règle, les étiquettes connues que la saisie propose, le nom de
// l'export. Les textes viennent de la traduction réelle ; les étiquettes et
// les noms d'épreuve sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { avisDeLObjectif } from '../../application/objectifs.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { creerCharge } from '../../stockage/document.js';
import { TABLES_INTERFACE } from '../libelles.js';
import {
OPTIONS_REGLE_PAR_DEFAUT,
basculerTable,
etiquettesConnues,
nomExportObjectifs,
objectifsDeLaTable,
optionsRegle,
regleChoisie,
regleParDefaut,
vueObjectif,
} from './objectifs.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const personne = (id, nom, etiquettes) => ({
id,
nom,
prenom: null,
appartenance: null,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
...(etiquettes === undefined ? {} : { etiquettes }),
});
const objectif = (tour, regle, etiquettes) => ({ portee: tour === null ? 'tous' : 'tour', tour, regle, etiquettes });
// Trois personnes, deux tables, trois tours : Iris Ombrelle porte
// « finance » et « Montréal », Théo Pervenche « bénévole », Ondine Lacasse
// rien. La table 1 vise « finance » pour la soirée, règle propre « ou », et
// « bénévole » au tour 2 ; la table 2, numérotée 7, « cuisine » au tour 3,
// en suivant le défaut.
function chargeDe({ regleObjectifs } = {}) {
const charge = creerCharge({ id: 'evt-objectifs', nom: 'Soirée des Merles', siegesParDefaut: 4, tours: 3 });
charge.participants = [
personne(1, 'Ombrelle', ['Montréal', 'finance']),
personne(2, 'Pervenche', ['bénévole']),
personne(3, 'Lacasse'),
];
charge.tables = [
{ id: 1, numero: 1, sieges: null, forme: 'ronde', position: { x: 0, y: 0 }, objectifs: [objectif(null, 'ou', ['finance']), objectif(2, null, ['bénévole'])] },
{ id: 2, numero: 7, sieges: null, forme: 'carree', position: { x: 300, y: 0 }, objectifs: [objectif(3, null, ['cuisine'])] },
];
if (regleObjectifs !== undefined) charge.reglages.regleObjectifs = regleObjectifs;
charge.prochainsIds = { participant: 4, table: 3, proposition: 1 };
return charge;
}
describe('vueObjectif : ce que montre un objectif (§ 4.5, D3)', () => {
test('l’objectif de la soirée, règle propre « ou » sous le défaut « ou » : son état dit « règle propre », jamais celui du défaut', () => {
const charge = chargeDe();
const vue = vueObjectif(charge, charge.tables[0], null);
assert.equal(vue.cle, '1');
assert.equal(vue.tour, null);
assert.equal(vue.existe, true);
assert.equal(vue.texte, 'finance');
assert.equal(vue.regle, 'ou');
assert.equal(vue.etat.cle, 'tables.objectif.reglePropre');
assert.equal(vue.etat.ton, 'information');
assert.equal(t(vue.etat.cle), 'Règle propre');
});
test('un objectif de tour qui suit le défaut : son état le dit, sa règle se choisit par la valeur vide', () => {
const charge = chargeDe();
const vue = vueObjectif(charge, charge.tables[0], 2);
assert.equal(vue.cle, '1-2');
assert.equal(vue.tour, 2);
assert.equal(vue.regle, '');
assert.equal(vue.etat.cle, 'tables.objectif.suitDefaut');
assert.equal(vue.etat.ton, 'neutre');
assert.notEqual(t(vue.etat.cle), t('tables.objectif.reglePropre'));
});
test('deux objectifs de même règle effective, l’un propre, l’autre qui suit le défaut : leurs états diffèrent', () => {
const charge = chargeDe();
charge.tables[0].objectifs[1].regle = null;
const propre = vueObjectif(charge, charge.tables[0], null);
const suit = vueObjectif(charge, charge.tables[0], 2);
assert.equal(regleParDefaut(charge), 'ou');
assert.notEqual(propre.etat.cle, suit.etat.cle);
assert.notEqual(t(propre.etat.cle), t(suit.etat.cle));
});
test('un couple sans objectif : champ vide, ni règle ni état ni avis', () => {
const charge = chargeDe();
const vue = vueObjectif(charge, charge.tables[0], 3);
assert.deepEqual(vue, { cle: '1-3', tour: 3, existe: false, texte: '', regle: '', etat: null, avis: [] });
});
test('les étiquettes s’écrivent jointes comme l’export, dans l’ordre du fichier ; les avis sont ceux de l’objectif', () => {
const charge = chargeDe();
charge.tables[0].objectifs[0].etiquettes = ['Montréal', 'finance'];
charge.tables[0].objectifs[0].regle = 'et';
assert.equal(vueObjectif(charge, charge.tables[0], null).texte, 'Montréal, finance');
const cuisine = vueObjectif(charge, charge.tables[1], 3);
assert.deepEqual(cuisine.avis, avisDeLObjectif(charge, 2, 3));
assert.deepEqual(
cuisine.avis.map(({ code }) => code),
['OBJECTIF_SANS_CORRESPONDANT', 'ETIQUETTE_ORPHELINE'],
);
});
test('objectifsDeLaTable : la soirée, puis un objectif par tour de 1 à R, et le nombre de ceux qui existent', () => {
const charge = chargeDe();
const { soiree, tours, parTour } = objectifsDeLaTable(charge, charge.tables[0]);
assert.equal(soiree.cle, '1');
assert.deepEqual(tours.map(({ cle }) => cle), ['1-1', '1-2', '1-3']);
assert.deepEqual(tours.map(({ existe }) => existe), [false, true, false]);
assert.equal(parTour, 1);
assert.equal(objectifsDeLaTable(charge, charge.tables[1]).soiree.existe, false);
const sans = { ...charge.tables[1] };
delete sans.objectifs;
assert.equal(objectifsDeLaTable(charge, sans).parTour, 0);
});
test('vueObjectif ne modifie pas la charge', () => {
const charge = chargeDe();
const avant = structuredClone(charge);
objectifsDeLaTable(charge, charge.tables[0]);
assert.deepEqual(charge, avant);
});
});
describe('les choix de règle (§ 4.5, D3)', () => {
test('regleParDefaut : « ou » sans réglage, « et » réglé', () => {
assert.equal(regleParDefaut(chargeDe()), 'ou');
assert.equal(regleParDefaut(chargeDe({ regleObjectifs: 'et' })), 'et');
});
test('optionsRegle : le réglage général, nommé par la règle qu’il donne, puis « au moins une (OU) » et « toutes (ET) »', () => {
const textes = (options) => options.map(({ valeur, cle, details }) => [valeur, t(cle, details)]);
assert.deepEqual(textes(optionsRegle('ou')), [
['', 'selon le réglage général (au moins une)'],
['ou', 'au moins une (OU)'],
['et', 'toutes (ET)'],
]);
assert.equal(t(optionsRegle('et')[0].cle, optionsRegle('et')[0].details), 'selon le réglage général (toutes)');
});
test('la règle par défaut se choisit entre « au moins une (OU) » et « toutes (ET) »', () => {
assert.deepEqual(
OPTIONS_REGLE_PAR_DEFAUT.map(({ valeur, cle }) => [valeur, t(cle)]),
[
['ou', 'au moins une (OU)'],
['et', 'toutes (ET)'],
],
);
});
test('regleChoisie : la valeur vide suit le défaut, null ; « ou » et « et » sont des règles propres', () => {
assert.equal(regleChoisie(''), null);
assert.equal(regleChoisie('ou'), 'ou');
assert.equal(regleChoisie('et'), 'et');
assert.throws(() => regleChoisie('peut-être'), RangeError);
});
});
describe('ce que la saisie propose, ce que l’export nomme (§ 4.5, § 10.4)', () => {
test('etiquettesConnues : personnes et objectifs, chacune une fois, par unités de code', () => {
assert.deepEqual(etiquettesConnues(chargeDe()), ['Montréal', 'bénévole', 'cuisine', 'finance']);
const vide = creerCharge({ id: 'evt-vide', nom: 'Soirée des Merles', siegesParDefaut: 4, tours: 1 });
assert.deepEqual(etiquettesConnues(vide), []);
});
test('nomExportObjectifs : ‹base›-objectifs.csv, sur le patron de l’export des participants', () => {
assert.equal(nomExportObjectifs('soiree'), 'soiree-objectifs.csv');
});
test('basculerTable : déplie une table repliée, replie une dépliée, sans toucher l’ensemble reçu', () => {
const vide = new Set();
const ouverte = basculerTable(vide, 4);
assert.deepEqual([...ouverte], [4]);
assert.deepEqual([...vide], []);
assert.deepEqual([...basculerTable(ouverte, 4)], []);
assert.deepEqual([...basculerTable(ouverte, 2)], [4, 2]);
});
});

View file

@ -54,7 +54,7 @@ const ENTREES = [
['data-conversion', 'plan : une conversion offerte par une liste de travail', null],
['data-corbeille', 'accueil : la corbeille', ['']],
['data-corbeille-dossier', 'accueil : un dossier de la corbeille', null],
['data-critere', 'propositions : un nom de critère de la phrase de l’ordre', null],
['data-critere', 'propositions : un nom de la phrase de l’ordre, par le critère qui classe sa valeur, objectifs et animateurs compris', null],
['data-defaut', 'tables : le réglage du défaut de places', ['']],
['data-demonstration', 'accueil : une démonstration, par sa clé', null],
['data-derive', 'événement : un placement en dérive, par son identifiant', null],
@ -75,6 +75,7 @@ const ENTREES = [
['data-etat', 'événement : un état du plan dans la tête', null],
['data-etat-table', 'tables : l’état d’une table', ['']],
['data-etendue', 'propositions : l’étendue d’une page du classement', ['']],
['data-etiquettes', 'participants : les étiquettes de la fiche, leurs pastilles et leur saisie', ['']],
['data-evenement', 'accueil : une ligne de la liste, par sa base', null],
['data-evidence', 'plan : la personne mise en évidence', null],
['data-fantome', 'plan : le fantôme d’une personne glissée', ['']],
@ -86,6 +87,7 @@ const ENTREES = [
['data-focus-initial', 'plan : le siège qui reçoit le focus à l’ouverture du panneau', null],
['data-fusion', 'import : un groupe d’orthographes fondu', ['']],
['data-fusions', 'import : les groupes d’orthographes', ['']],
['data-fusions-etiquettes', 'import : les étiquettes fondues, à part des appartenances', ['']],
['data-groupe', 'participants : l’en-tête d’un groupe, par sa clé', null],
['data-groupe-compte', 'participants : le compte d’un groupe', ['']],
['data-groupe-conflits', 'participants : la marque de conflit d’un groupe', ['']],
@ -98,6 +100,7 @@ const ENTREES = [
['data-hors-classement', 'propositions : les lignes hors classement', ['']],
['data-issue', 'génération : l’issue de la dernière génération', ['']],
['data-legende', 'plan : la légende des conflits', ['']],
['data-legende-etiquettes', 'participants : la légende des étiquettes', ['']],
['data-legende-pastille', 'plan : la pastille de la légende', ['']],
['data-legende-regle', 'plan : une entrée de la légende, par sa règle', null],
['data-ligne', 'import : une ligne de l’aperçu, par son rang', null],
@ -116,14 +119,18 @@ const ENTREES = [
['data-menu', 'plan : le menu d’une personne', null],
['data-mode', 'événement : la barre, la tête et la surface de l’événement, par son mode', null],
['data-mode-libelle', 'le libellé de mode', ['bandeau', 'barre']],
['data-mode-objectifs', 'génération : le groupe du choix du mode des objectifs', ['']],
['data-motif', 'import : le motif d’une ligne refusée', ['']],
['data-multiligne', 'import : une ligne sur plusieurs lignes de texte', null],
['data-nature-import', 'import : le choix de la nature du fichier', ['']],
['data-niveau', 'participants : le niveau d’un groupe ou d’un sélecteur', null],
['data-nom-imprime', 'aperçu : le nom entier d’une personne sur une liste imprimée', ['']],
['data-nombre', 'import : un nombre affiché', ['']],
['data-objectif', 'tables : l’objectif d’une table, par sa table, et son tour pour un tour désigné', null],
['data-ordre', 'propositions : la phrase de l’ordre', ['']],
['data-orthographe', 'import : une orthographe d’un groupe', ['']],
['data-pages', 'propositions : une nav des pages du classement, haut ou bas', null],
['data-palier', 'génération : un palier de l’éditeur des priorités, par son index', null],
['data-panneau', 'plan : un panneau', ['place', 'table']],
['data-panneau-diagnostic', 'plan : le panneau du diagnostic', ['']],
['data-papier', 'le papier de la machine : la question, ou le papier choisi', ['choisi', 'question']],
@ -135,7 +142,11 @@ const ENTREES = [
['data-place-fixe', 'événement : la place fixe de la barre de l’événement', ['']],
['data-place-vide', 'aperçu : une place vide de la feuille par table', ['']],
['data-plan', 'plan : le <svg> du plan', ['']],
['data-plancher-objectifs', 'diagnostic : la phrase d’un plancher des objectifs, par la valeur que le mode compare', null],
['data-poignee', 'plan : la poignée d’une table', null],
['data-portee', 'tables : la portée d’un objectif, tous pour la soirée, tour pour un tour désigné', ['tous', 'tour']],
['data-priorite', 'génération : un critère de l’éditeur des priorités, par son nom', null],
['data-priorites', 'génération : la liste des paliers de l’éditeur des priorités', ['']],
['data-proposition', 'propositions : une ligne, par son identifiant', null],
['data-question', 'une question posée : dossier de travail ou durée longue', null],
['data-rapport', 'import : le rapport', ['']],
@ -143,6 +154,7 @@ const ENTREES = [
['data-refus-realigner', 'tables : le refus du réalignement', ['']],
['data-refusees', 'import : les lignes refusées', ['']],
['data-reglages', 'génération : les réglages', ['']],
['data-regle-objectifs', 'tables : la règle par défaut des objectifs', ['']],
['data-remede', 'diagnostic : un remède', ['']],
['data-reserve', 'plan : la réserve d’un tour', null],
['data-retenu', 'propositions : la ligne du retenu', null],

View file

@ -24,6 +24,7 @@ import { appliquerCommande } from '../../src/application/commandes.js';
import { ErreurAnnulee } from '../../src/application/generation.js';
import { texteLivre } from '../../src/demo/livrees.js';
import { SECTIONS } from '../../src/interface/evenement/sections.js';
import { LISTE_PAR_DEFAUT, listeApres } from '../../src/interface/generation/priorites.js';
import { rechercher } from '../../src/moteur/recherche.js';
import { serialiser } from '../../src/stockage/canonique.js';
import { analyser, capacite, configurationDepuisCharge } from '../../src/stockage/document.js';
@ -652,6 +653,29 @@ export const ECRANS = Object.freeze([
return m.racine;
},
},
{
nom: 'participants.etiquettes',
mode: 'ecriture',
async atteindre(contexte) {
// Deux personnes étiquetées, puis la fiche d'une troisième, où
// « FINANCE » tapé au clavier s'enregistre sous l'orthographe que
// l'événement porte : les pastilles neutres et leur « Retirer », le
// signal d'orthographe contre le champ, et la légende des étiquettes
// sous celle des appartenances.
let charge = chargePrincipale();
charge = executer(charge, 'changerEtiquettes', { id: 1, ajouter: 'finance, Montréal' });
charge = executer(charge, 'changerEtiquettes', { id: 2, ajouter: 'bénévole' });
const m = await ouvrirEvenement(contexte, charge, { ecriture: true, section: 'participants' });
await cliquer(m, '[data-participant="3"] [data-commande="fiche"]');
const champ = await element(m, '[data-fiche="3"] [data-champ="etiquettes"]');
champ.focus();
for (const caractere of 'FINANCE') await touche(caractere);
await touche('Enter');
await element(m, '[data-fiche="3"] [data-etiquettes] [data-avertissement="ORTHOGRAPHE_RETENUE"]');
await element(m, '[data-legende-etiquettes]');
return m.racine;
},
},
{
nom: 'participants.groupes',
mode: 'ecriture',
@ -732,6 +756,27 @@ export const ECRANS = Object.freeze([
return m.racine;
},
},
{
nom: 'tables.objectifs',
mode: 'ecriture',
async atteindre(contexte) {
// La règle par défaut dans la carte des réglages ; la table 1 vise
// « finance » pour la soirée sous sa règle propre, et au tour 2 en
// suivant le défaut, ses objectifs par tour dépliés ; la table 2 vise
// « cuisine », que personne ne porte : ses avis contre l'objectif.
let charge = chargePrincipale();
charge = executer(charge, 'changerEtiquettes', { id: 1, ajouter: 'finance' });
charge = executer(charge, 'poserObjectif', { table: 1, tour: null, etiquettes: 'finance', regle: 'ou' });
charge = executer(charge, 'poserObjectif', { table: 1, tour: 2, etiquettes: 'finance', regle: null });
charge = executer(charge, 'poserObjectif', { table: 2, tour: null, etiquettes: 'cuisine', regle: null });
const m = await ouvrirEvenement(contexte, charge, { ecriture: true, section: 'tables' });
await element(m, '[data-regle-objectifs]');
await cliquer(m, '[data-section="tables"] [data-table="1"] [data-commande="basculerObjectifsTours"]');
await element(m, '[data-objectif="1-2"][data-portee="tour"]');
await element(m, '[data-objectif="2"] [data-avertissement="ETIQUETTE_ORPHELINE"]');
return m.racine;
},
},
{
nom: 'evenement.avertissements',
mode: 'lecture',
@ -759,6 +804,38 @@ export const ECRANS = Object.freeze([
return m.racine;
},
},
{
nom: 'generation.priorites',
mode: 'ecriture',
async atteindre(contexte) {
// L'éditeur des priorités sur un ordre réglé — l'excédent et les
// collisions cumulées au même palier, chacun son poids —, puis la
// redondance jointe aux animateurs : l'annonce du geste paraît.
const paliers = listeApres(LISTE_PAR_DEFAUT, 'joindrePalier', 'collisionsCumulees');
const charge = executer(chargePrincipale(), 'changerPriorites', { paliers });
const m = await ouvrirEvenement(contexte, charge, { ecriture: true, section: 'generation' });
await cliquer(m, '[data-section="generation"] [data-priorite="redondance"] [data-commande="joindrePalier"]');
await attendre("l'annonce du geste", () => {
const annonce = m.racine.querySelector('[data-section="generation"] [data-priorites] ~ [role="status"]');
return (annonce?.textContent.trim() ?? '') !== '';
});
return m.racine;
},
},
{
nom: 'generation.modeObjectifs',
mode: 'ecriture',
async atteindre(contexte) {
// Les tours hors objectif partagent un palier : choisir « l'équité
// d'abord » se refuse contre le groupe du mode, le choix revenu.
const paliers = listeApres(LISTE_PAR_DEFAUT, 'joindrePalier', 'toursHorsObjectif');
const charge = executer(chargePrincipale(), 'changerPriorites', { paliers });
const m = await ouvrirEvenement(contexte, charge, { ecriture: true, section: 'generation' });
await cliquer(m, '[data-section="generation"] [data-mode-objectifs] input[value="minimum"]');
await element(m, '[data-mode-objectifs] [data-refus="OBJECTIFS_PALIER_PARTAGE"]');
return m.racine;
},
},
{
nom: 'propositions.critereSurvole',
mode: 'lecture',

View file

@ -357,6 +357,12 @@ const PICTOGRAMME = /\p{Extended_Pictographic}/u;
// remèdes disent qu'un changement « la rend » ou « la rendent évitable ».
// Ailleurs, « évitable » nommerait le second segment du manque (§ 12.4), que
// le logiciel n'écrit pas.
//
// Le troisième mode des objectifs garde le libellé que l'utilisateur lui a
// donné, « l'équité d'abord » (D22) : il nomme le minimum d'abord (§ 12.1),
// jamais un indice d'équité (§ 12.8). La forme exacte s'admet sous la seule
// clé de ce libellé ; son explication, et tout autre texte, ne l'emploient
// pas.
const EXCEPTIONS_DE_CONTENU = [
{
mot: 'évitable',
@ -364,8 +370,17 @@ const EXCEPTIONS_DE_CONTENU = [
forme: /\bla rend(?:ent)? evitable\b/g,
nom: 'diagnostic.remede.* « la rend(ent) évitable »',
},
{
mot: 'équité',
cles: /^generation\.reglage\.modeObjectifs\.minimum$/,
forme: /(?<![\p{L}\p{N}])l'equite d'abord(?![\p{L}\p{N}])/gu,
nom: "generation.reglage.modeObjectifs.minimum « l'équité d'abord » (D22)",
},
];
// Les exceptions d'un seul mot, pour une épreuve qui n'éprouve que lui.
const exceptionsDu = (mot) => EXCEPTIONS_DE_CONTENU.filter((exception) => exception.mot === mot);
// Une chaîne de forme clé — un code, LECTURE_SEULE, ou un libellé,
// plan.refus.fermer — : une fonction la passe à texte, ou la compare à un
// détail, et ne l'affiche pas.
@ -1326,14 +1341,107 @@ describe('sources de l’interface : le contenu des tables de traduction (§ 5.5
['plan.segment', 'la rend évitable'],
['diagnostic.remede.inevitable', 'inévitable'],
]);
assert.deepEqual(releverContenuTables([tables]), [
assert.deepEqual(releverContenuTables([tables], () => null, exceptionsDu('évitable')), [
'diagnostic.remede.autre : évitable « la place devient évitable »',
'diagnostic.remede.reste : évitable « la rend évitable, part évitable »',
'diagnostic.titre : évitable « un tabouret la rend évitable »',
'plan.segment : évitable « la rend évitable »',
]);
const sansRemede = new Map([['diagnostic.remede.un', 'ajouter une table']]);
assert.deepEqual(releverContenuTables([sansRemede]), ['exception sans objet : diagnostic.remede.* « la rend(ent) évitable »']);
assert.deepEqual(releverContenuTables([sansRemede], () => null, exceptionsDu('évitable')), [
'exception sans objet : diagnostic.remede.* « la rend(ent) évitable »',
]);
});
test('rouge : l’exception D22 n’admet « l’équité d’abord » que sous la clé du libellé du troisième mode ; tout autre emploi d’« équité » se relève, l’explication du mode comprise', () => {
const tables = new Map([
['generation.reglage.modeObjectifs.minimum', 'l\u{2019}équité d\u{2019}abord'],
['generation.reglage.modeObjectifs.minimum.explication', "l'équité d'abord, puis le total"],
['generation.reglage.modeObjectifs.chacun', "L'ÉQUITÉ D'ABORD"],
['propositions.phrase', "l'équité"],
['diagnostic.remede.un', '2 chaises de plus la rend évitable'],
]);
assert.deepEqual(releverContenuTables([tables]), [
"generation.reglage.modeObjectifs.chacun : équité « L'ÉQUITÉ D'ABORD »",
"generation.reglage.modeObjectifs.minimum.explication : équité « l'équité d'abord, puis le total »",
"propositions.phrase : équité « l'équité »",
]);
// Sous sa clé, une autre forme du mot se relève encore.
const autre = new Map([
['generation.reglage.modeObjectifs.minimum', "l'équité d'abord, une équité"],
['diagnostic.remede.un', 'la rend évitable'],
]);
assert.deepEqual(releverContenuTables([autre]), [
"generation.reglage.modeObjectifs.minimum : équité « l'équité d'abord, une équité »",
]);
const sansMode = new Map([['diagnostic.remede.un', 'la rend évitable']]);
assert.deepEqual(releverContenuTables([sansMode]), [
"exception sans objet : generation.reglage.modeObjectifs.minimum « l'équité d'abord » (D22)",
]);
});
});
// --- Garde : l'état des critères se lit, il ne se recalcule pas ---------------
// Les sources qui écrivent l'état « décoché » ou « sans objet » d'un critère
// (§ 5.3, A8) : l'éditeur des priorités, les réglages qui portent le choix du
// mode, et leur modèle.
const SOURCES_DE_L_ETAT = [
'src/interface/generation/Priorites.svelte',
'src/interface/generation/Reglages.svelte',
'src/interface/generation/priorites.js',
];
// Ce que la garde relève d'un « sans objet » recalculé : une variable ou une
// clé sansObjet qui reçoit une valeur, et chaque lecture des objets de la
// charge d'où il se déduirait — tables, participants, titres, réservations,
// objectifs, étiquettes, animations. Ces sources lisent l'état dans la table
// d'évaluation, qui le tient du moteur (§ 13.2) : aucune n'en a l'emploi. Une
// lecture est un accès pointé, un accès entre crochets par une chaîne, ou
// une déstructuration qui nomme l'objet — un motif entre accolades suivi de
// « = », hors « == » et « => », ou de la parenthèse d'un paramètre. Les
// commentaires ne calculent rien et se retirent d'abord ; un commentaire de
// ligne commence en début de ligne ou après un blanc.
const OBJETS_DE_LA_CHARGE = '(?:tables|participants|titres|reservations|objectifs|etiquettes|anime)';
function relevesSansObjet(texte) {
const source = texte
.replace(/\/\*[\s\S]*?\*\//g, ' ')
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/(^|\s)\/\/.*$/gm, ' ');
const motifs = [
/\bsansObjet\s*(?::|=(?!=))/g,
new RegExp(`\\.${OBJETS_DE_LA_CHARGE}\\b`, 'g'),
new RegExp(`\\{[^{}]*\\b${OBJETS_DE_LA_CHARGE}\\b[^{}]*\\}\\s*(?:=(?![=>])|\\))`, 'g'),
new RegExp(`\\[\\s*(['"\`])${OBJETS_DE_LA_CHARGE}\\1\\s*\\]`, 'g'),
];
return motifs.flatMap((motif) => [...source.matchAll(motif)].map(([trouve]) => trouve.replace(/\s+/g, ' ')));
}
describe('sources de l’interface : l’état des critères, lu dans la table d’évaluation (§ 5.3, § 13.2, A8)', () => {
test('rouge : la garde relève un « sans objet » recalculé, et laisse passer une lecture de l’état', () => {
assert.deepEqual(relevesSansObjet('const sansObjet = charge.tables.every((t) => t.objectifs === undefined);'), [
'sansObjet =',
'.tables',
'.objectifs',
]);
assert.deepEqual(relevesSansObjet('const vide = { coche: true, sansObjet: false };'), ['sansObjet:']);
assert.deepEqual(relevesSansObjet('const muet = etat.sansObjet === true; // charge.tables'), []);
// Une déstructuration, en déclaration ou en paramètre, et un accès entre
// crochets lisent les mêmes objets sans point.
const deguise =
'const { tables: lesTables } = charge;\n' +
"const aucun = $derived(lesTables.every((t) => (t['objectifs'] ?? []).length === 0));\n" +
'const compte = ({ participants }) => participants.length;';
assert.deepEqual(relevesSansObjet(deguise), ['{ tables: lesTables } =', '{ participants })', "['objectifs']"]);
assert.deepEqual(relevesSansObjet("const { table, refus } = evaluation(charge);\nconst etat = etats?.get('tables');"), []);
});
test('l’éditeur des priorités, les réglages et leur modèle ne recalculent pas l’état d’un critère', () => {
for (const chemin of SOURCES_DE_L_ETAT) {
const source = readFileSync(join(RACINE, chemin), 'utf8');
assert.ok(source.includes('etatAffiche'), `${chemin} : l'état ne s'y lit plus, la garde est à revoir`);
assert.deepEqual(relevesSansObjet(source), [], chemin);
}
});
});