[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 | | Nouveaux voisins à chaque tour | Génération | cochée |
| Nouvelle table à 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 | | 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 | | 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 | | Cible de la somme de redondance | Propositions | 1 000 |
<!-- engendre:fin reglages --> <!-- 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 * 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 * 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] * @param {ReadonlyArray<import('../src/interface/generation/reglages.js').EntreeReglage>} [definition]
* @returns {string} * @returns {string}
*/ */
export function tableauReglages(definition = REGLAGES_GENERATION) { 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 === 'champ') return nombre(valeur);
if (genre === 'bascule') return valeur ? 'cochée' : 'décochée'; 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}`); throw new RangeError(`reglages : genre inconnu ${JSON.stringify(genre)} pour ${cle}`);
}; };
return tableau( 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', () => { 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()); const lignes = lignesDuTableau(tableauReglages());
assert.equal(lignes.length, REGLAGES_GENERATION.length); 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) => { 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}`), ecrit[entree.genre](entree)]);
assert.deepEqual(lignes[rang], [t(entree.etiquette), t(`coquille.section.${entree.section}`), attendu]);
}); });
// 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 ajoutee = { ...REGLAGES_GENERATION[0], etiquette: 'generation.reglage.graine', defaut: 4_294_967_295 };
const avecAjout = lignesDuTableau(tableauReglages([...REGLAGES_GENERATION, ajoutee])); 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']); 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 }; 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 * @typedef {Object} LigneEvaluation
* @property {number} id celui de la proposition ; retenu.proposition pour le retenu * @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: 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.'); 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 }); 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); 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 }) => ({ valeur }) =>
`Un poids est un nombre entier de 1 à ${POIDS_MAX}` + (Number.isFinite(valeur) ? ` : ${nombreEcrit(valeur)} ne l'est pas.` : '.'), `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', 'OBJECTIFS_PALIER_PARTAGE',
({ mode, palier }, texte) => ({ mode, palier }, texte) => {
`Le mode ${cite(nomMode(mode))} compare deux valeurs l'une après l'autre : les ` + nomMode(mode);
`${cite(texte('mobilier.critere.toursHorsObjectif'))} s'y rangent seuls à leur palier, et partagent le palier ` + return (
`${numeroDePalier(palier)}. Les séparer d'abord dans l'ordre des priorités : le mode reste celui d'avant.`, `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 // 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 // (§ 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 { lireEtiquettes } from '../stockage/etiquettes.js';
import { refus } from './erreurs.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 // Deux chaînes comparées unité UTF-16 par unité : l'ordre où le fichier
// d'état range une liste d'étiquettes (§ 8.9). // d'état range une liste d'étiquettes (§ 8.9).
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0); 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)', () => { 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 () => { 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')); 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 // 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. // 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 // Une configuration que le moteur refuse n'a pas de diagnostic : son
// code, traduit, se pose à sa place, jamais le message de l'erreur. // code, traduit, se pose à sa place, jamais le message de l'erreur.
import { getContext } from 'svelte'; import { getContext } from 'svelte';
@ -35,7 +40,7 @@
import Commande from '../primitives/Commande.svelte'; import Commande from '../primitives/Commande.svelte';
import Pastille from '../primitives/Pastille.svelte'; import Pastille from '../primitives/Pastille.svelte';
import Refus from '../primitives/Refus.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(); let { compact = false } = $props();
@ -49,7 +54,11 @@
const liste = $derived( const liste = $derived(
resultat === null || resultat.diagnostic === null 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)); const cartes = $derived(cartesDiagnostic(liste));
// Le rang de teinte de chaque appartenance, sur tous les participants. // Le rang de teinte de chaque appartenance, sur tous les participants.
@ -72,7 +81,7 @@
</svelte:element> </svelte:element>
<ul aria-labelledby={id}> <ul aria-labelledby={id}>
{#each carte.enonces as enonce, n (n)} {#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> <p class="enonce"><span data-phrase>{t(enonce.cle, enonce.details)}</span></p>
{#if !compact && enonce.remede !== null} {#if !compact && enonce.remede !== null}
{@const action = actionDe(enonce.remede)} {@const action = actionDe(enonce.remede)}

View file

@ -16,11 +16,19 @@
// un texte. // un texte.
// //
// L'ordre est celui du § 5.6 — collisions et excédent, redondance, // 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 // animateurs, ancrage, écart d'itinéraire, manque de places, objectifs hors
// qui vide la séparation de son sens (§ 15.4, § 7.5) et suivi du plafond a // d'atteinte —, précédé de ce qui vide la séparation de son sens (§ 15.4,
// priori (§ 5.5) et des retours imposés (§ 5.4). Un plancher ne s'énonce que // § 7.5) et suivi du plafond a priori (§ 5.5) et des retours imposés
// strictement positif : un plancher nul laisserait croire qu'un plancher a // (§ 5.4). Un plancher ne s'énonce que strictement positif : un plancher nul
// été calculé là où il n'y en a pas. // 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 // Désactiver la séparation : le seul remède que réalise une commande. Un
// objet neuf à chaque appel. // objet neuf à chaque appel.
@ -127,6 +135,51 @@ function placesManquantes({ placesManquantes: manquantes }) {
return [{ cle: 'diagnostic.placesManquantes', details: { placesManquantes: manquantes }, remede: remede('diagnostic.remede.places') }]; 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 // 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. // population vide n'en a aucun, et l'énoncé le dit par le tiret.
function plafond({ plafondAPriori }) { function plafond({ plafondAPriori }) {
@ -155,13 +208,16 @@ function imposes({ retoursImposes, ancragesImposantRetours }) {
* le diagnostic. * le diagnostic.
* *
* @param {Object} diagnostic celui que rend diagnostiquerCharge * @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 * 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, * @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 [ return [
...separation(diagnostic), ...separation(diagnostic),
...collisions(diagnostic, tours), ...collisions(diagnostic, tours),
@ -170,6 +226,7 @@ export function enonces(diagnostic, { numeroDe, tours }) {
...ancrage(diagnostic, numeroDe), ...ancrage(diagnostic, numeroDe),
...ecartItineraire(diagnostic), ...ecartItineraire(diagnostic),
...placesManquantes(diagnostic), ...placesManquantes(diagnostic),
...objectifs(diagnostic, criteres),
...plafond(diagnostic), ...plafond(diagnostic),
...imposes(diagnostic), ...imposes(diagnostic),
]; ];

View file

@ -16,7 +16,7 @@ import { texteLivre } from '../../demo/livrees.js';
import { diagnostiquer } from '../../moteur/diagnostic.js'; import { diagnostiquer } from '../../moteur/diagnostic.js';
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js'; import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { TABLES_INTERFACE } from '../libelles.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 { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const TIRET = '\u{2014}'; const TIRET = '\u{2014}';
@ -58,11 +58,28 @@ function chargeDe({ appartenances, capacites, tours = 4, ancres = [], separer =
const fois = (n, valeur) => Array(n).fill(valeur); 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) { function lire(charge) {
const { diagnostic, erreur } = diagnostiquerCharge(charge); const { diagnostic, erreur } = diagnostiquerCharge(charge);
assert.equal(erreur, null); 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); const cles = (liste) => liste.map(({ cle }) => cle);
@ -120,6 +137,9 @@ const CONFIGURATIONS = {
petiteConflit: () => chargeDemo('petite-conflit'), petiteConflit: () => chargeDemo('petite-conflit'),
grande: () => chargeDemo('grande'), grande: () => chargeDemo('grande'),
grandeSansException: () => chargeDemo('grande-sans-exception'), 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é ------------------------------------------------------ // --- 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', champ: 'plafondAPriori',
attendus: (d) => 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', () => { describe('enonces : aux bords', () => {
test('un événement neuf, zéro participant, zéro table : aucun plancher, le plafond a priori « — », jamais 0', () => { 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: [] })); 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 // 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 ; // 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 // 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, // Le module de l'évaluation est remplacé par ses propres fonctions,
// espionnées (vi.mock, spy) : evaluer garde son effet, et ses appels se // 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 anciens = charge(m).propositions.map(({ id }) => id);
const avantEcriture = appels(); const avantEcriture = appels();
await m.seance.executer('effacerPropositions', {}); 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 }); 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 attendu = rangUnAttendu(m);
const apres = appels(); const apres = appels();
expect(identifiant(m).textContent).toBe(attendu); 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 { monterApp } from '../../../test/navigateur/montage.js';
import { composantes, contraste, sousTheme } from '../../../test/navigateur/rendu.js'; import { composantes, contraste, sousTheme } from '../../../test/navigateur/rendu.js';
import { TABLES_INTERFACE } from '../libelles.js'; import { TABLES_INTERFACE } from '../libelles.js';
import { REGLAGES_GENERATION, valeurDuReglage } from './reglages.js';
import Travailleur from './travailleur.js?worker&inline'; import Travailleur from './travailleur.js?worker&inline';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
@ -295,7 +296,7 @@ async function regler(m, reglages) {
describe('les réglages (§ 5.7, § 5.10)', () => { 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 () => { 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 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.length).toBe(4);
expect(entrees.map((entree) => entree.dataset.champ)).toEqual(CHAMPS); expect(entrees.map((entree) => entree.dataset.champ)).toEqual(CHAMPS);
for (const cle of 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)', () => { describe('les contraintes et l’attribution des sièges (§ 5.3)', () => {
// Les cinq bascules, dans l'ordre du § 5.3. // Les sept bascules, dans l'ordre du § 5.3 ; les deux cases facultatives
const BASCULES = ['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable', 'varierAppartenances', 'attribuerSieges']; // 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 = [ const NOMS_DU_SPEC = [
'Séparer les appartenances', 'Séparer les appartenances',
'Nouveaux voisins à chaque tour', 'Nouveaux voisins à chaque tour',
'Nouvelle table à chaque tour', 'Nouvelle table à chaque tour',
'Varier les appartenances rencontrées', 'Varier les appartenances rencontrées',
'Respecter les objectifs des tables',
"Rencontrer le plus d'animateurs différents",
'Attribuer les sièges', '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 m = await demonstration('petite', { executeur: creerExecuteurDirect() });
const cases = [...section(m).querySelectorAll('input[type="checkbox"]')]; const cases = [...section(m).querySelectorAll('input[type="checkbox"]')];
expect(cases.map((caseACocher) => caseACocher.dataset.champ)).toEqual([...BASCULES, 'coeursAutomatiques']); expect(cases.map((caseACocher) => caseACocher.dataset.champ)).toEqual([...BASCULES, 'coeursAutomatiques']);
BASCULES.forEach((cle, rang) => { BASCULES.forEach((cle, rang) => {
expect(etiquette(m, cle).textContent).toBe(t(`generation.contrainte.${cle}`)); expect(etiquette(m, cle).textContent).toBe(t(`generation.contrainte.${cle}`));
expect(t(`generation.contrainte.${cle}`)).toBe(NOMS_DU_SPEC[rang]); 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) { for (const cle of BASCULES) {
const avant = structuredClone(charge(m).reglages); 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)); await cliquer(m, bascule(m, cle));
expect(revision(m), cle).toBe(r0 + 1); expect(revision(m), cle).toBe(r0 + 1);
expect(journalDe(m).entrees.length, cle).toBe(entrees + 1); expect(journalDe(m).entrees.length, cle).toBe(entrees + 1);
expect(charge(m).reglages, cle).toEqual({ ...avant, [cle]: !avant[cle] }); expect(charge(m).reglages, cle).toEqual({ ...avant, [cle]: !valeurLue(avant, cle) });
expect(bascule(m, cle).checked, cle).toBe(!avant[cle]); expect(bascule(m, cle).checked, cle).toBe(!valeurLue(avant, cle));
} }
// La barre d'espace bascule aussi, par le même geste. // La barre d'espace bascule aussi, par le même geste.
const cle = BASCULES[0]; 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, // 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 ; // 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 // 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 // l'écran de même.
// même traduction que celle de l'application ; les noms d'épreuve sont //
// inventés. // 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 // Le projet navigateur emploie expect, et test et describe de Vitest
// (§ 14.8). // (§ 14.8).
@ -22,12 +29,18 @@ import { creerHorlogePilotee } from '../../application/horloge.js';
import { TABLES_APPLICATION } from '../../application/libelles.js'; import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerSeance } from '../../application/seance.js'; import { creerSeance } from '../../application/seance.js';
import { creerTraduction } from '../../application/traduction.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 { VERSION } from '../../version.genere.js';
import { creerFichiersSimules } from '../../../test/fichiers_simules.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 { composantes, sousTheme } from '../../../test/navigateur/rendu.js';
import { TABLES_INTERFACE } from '../libelles.js'; import { TABLES_INTERFACE } from '../libelles.js';
import Reglages from './Reglages.svelte'; import Reglages from './Reglages.svelte';
import { LISTE_PAR_DEFAUT, admissionSous, etatAffiche, etatsDesCriteres, gestesOfferts, listeApres } from './priorites.js';
import { REGLAGES_GENERATION, reglagesDeSection } from './reglages.js'; import { REGLAGES_GENERATION, reglagesDeSection } from './reglages.js';
// Les entrées que l'écran de la génération montre : celles de sa section. // 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. // 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); 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é. // 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 entree = (cible, cle) => cible.querySelector(`input[data-champ="${cle}"]`);
const libelleDe = (cible, cle) => cible.querySelector(`label[for="${entree(cible, cle).id}"]`).textContent.trim(); 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(); const cible = await monter();
expect(DE_LA_GENERATION.length).toBeGreaterThan(0); expect(DE_LA_GENERATION.length).toBeGreaterThan(0);
expect(DE_LA_GENERATION.length).toBeLessThan(REGLAGES_GENERATION.length); 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(); 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)); expect(libelleDe(cible, cle), cle).toBe(t(etiquette));
// Une charge neuve porte les défauts de la définition. // Une charge neuve porte les défauts de la définition.
if (genre === 'bascule') expect(entree(cible, cle).checked, cle).toBe(defaut); 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 }, props: { definition },
}); });
const cles = DE_LA_GENERATION.map(({ cle: c }) => c); const [premier, ...autres] = designationsAttendues(REGLAGES_GENERATION);
expect(designations(cible)).toEqual(['graine', cles[0], 'generation.epreuve', ...cles.slice(1), 'epreuveBascule', 'coeursAutomatiques']); expect(designations(cible)).toEqual(['graine', premier, 'generation.epreuve', ...autres, 'epreuveBascule', 'coeursAutomatiques']);
expect(libelleDe(cible, 'generation.epreuve')).toBe(t('generation.coeurs.titre')); expect(libelleDe(cible, 'generation.epreuve')).toBe(t('generation.coeurs.titre'));
expect(entree(cible, 'generation.epreuve').value).toBe('4321'); expect(entree(cible, 'generation.epreuve').value).toBe('4321');
expect(libelleDe(cible, 'epreuveBascule')).toBe(t('generation.contraintes.titre')); expect(libelleDe(cible, 'epreuveBascule')).toBe(t('generation.contraintes.titre'));
@ -179,8 +221,342 @@ describe('l’habillage des réglages', () => {
} }
}); });
} }
const legendes = [...cible.querySelectorAll('legend')]; // Les contraintes, l'ordre des priorités et les cœurs : trois groupes ;
expect(legendes.length).toBe(2); // 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'); 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 // « Générer ». Son refus, refusGraine, se pose contre elle ; une frappe
// appelle saisirGraine, qui le retire. // 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 // cases à cocher, chacune sous son nom du spec : un clic, ou la barre
// d'espace, exécute modifierReglage {cle, valeur} — une entrée par // d'espace, exécute modifierReglage {cle, valeur} — une entrée par
// bascule. À l'issue du geste, la case montre la valeur de la charge, // 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 // bloqué, support rompu — se pose à côté de la case, avec son remède, et
// rien ne s'écrit. // 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 // 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 // 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 // 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 Champ from '../primitives/Champ.svelte';
import Refus from '../primitives/Refus.svelte'; import Refus from '../primitives/Refus.svelte';
import { effetDe } from '../raccourcis.js'; 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'; import { REGLAGES_GENERATION, reglagesDeSection, reglagesDuGenre, valeurDuReglage } from './reglages.js';
let { let {
@ -111,6 +129,14 @@
const deLaGeneration = $derived(reglagesDeSection(definition, 'generation')); const deLaGeneration = $derived(reglagesDeSection(definition, 'generation'));
const champs = $derived(reglagesDuGenre(deLaGeneration, 'champ')); const champs = $derived(reglagesDuGenre(deLaGeneration, 'champ'));
const bascules = $derived(reglagesDuGenre(deLaGeneration, 'bascule')); 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 // 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 // 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); 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> </script>
<div class="reglages" data-reglages> <div class="reglages" data-reglages>
@ -190,8 +238,43 @@
<Refus refus={refusBascule.refus} lever={() => (refusBascule = null)} /> <Refus refus={refusBascule.refus} lever={() => (refusBascule = null)} />
{/if} {/if}
</div> </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} {/each}
</fieldset> </fieldset>
{#each ordres as entree (entree.cle)}
<Priorites {charge} {entree} {etats} />
{/each}
<fieldset class="coeurs" data-coeurs> <fieldset class="coeurs" data-coeurs>
<legend>{t('generation.coeurs.titre')}</legend> <legend>{t('generation.coeurs.titre')}</legend>
<div class="bascule"> <div class="bascule">
@ -319,6 +402,52 @@
margin: 0; 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 { p {
margin: 0; margin: 0;
} }

View file

@ -43,6 +43,11 @@
// section, une seule, sur l'explication de cette colonne, et le quitter la // section, une seule, sur l'explication de cette colonne, et le quitter la
// referme ; le terme expliqué porte aria-describedby vers elle. // 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 // Habillage : la meilleure proposition est une carte à elle, chaque valeur
// sous son intitulé ; les réglages, « Générer » — l'action principale de // 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 // 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 // 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. // 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 // nom son nom dans charge.reglages, ou dans charge.reglages.generation
// pour un champ de la génération ; // pour un champ de la génération ;
// cle son chemin dans charge.reglages, que la commande qui l'écrit // 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 ; // generation.<nom> pour un champ de la génération, <nom> sinon ;
// etiquette la clé du libellé, qui porte l'unité pour un champ ; // etiquette la clé du libellé, qui porte l'unité pour un champ ;
// unite ce qu'un champ compte, 'propositions' ou 'mouvements' ; null // 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 // defaut la valeur d'une charge neuve, ou celle d'un réglage facultatif
// que la charge ne porte pas ; // 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 // section la section de l'écran qui le montre : 'generation', écrit par
// modifierReglage, ou 'propositions', la cible de la somme de // modifierReglage — l'ordre par changerPriorites —, ou
// redondance, écrite par changerCibleRedondance. // '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 // La graine, saisie de la commande, et les cœurs, réglage du poste, ne sont
// pas au fichier : Reglages.svelte les montre à part. // 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 // 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 // commande de création la pose (chargeNeuve) : creerCharge y copie
// GENERATION_PAR_DEFAUT et y écrit les bascules, et l'interface n'importe // 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, // pas le stockage. La charge lue ici n'est écrite nulle part. Les réglages
// facultative, n'y est pas : son défaut est CIBLE_REDONDANCE_PAR_DEFAUT. // 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 { 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 { 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 {'generation'|'propositions'} SectionReglage */
/** /**
* @typedef {Readonly<{nom: string, cle: string, etiquette: string, unite: 'propositions'|'mouvements'|null, * @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( const { reglages: DEFAUTS } = chargeNeuve(
{ nom: 'reglages', siegesParDefaut: SIEGES_PAR_DEFAUT, tours: TOURS_PAR_DEFAUT }, { nom: 'reglages', siegesParDefaut: SIEGES_PAR_DEFAUT, tours: TOURS_PAR_DEFAUT },
() => 'reglages', () => 'reglages',
@ -58,17 +83,49 @@ const champ = (nom, unite) =>
section: 'generation', 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({ Object.freeze({
nom, nom,
cle: nom, cle: nom,
etiquette: `generation.contrainte.${nom}`, etiquette: `generation.contrainte.${nom}`,
unite: null, unite: null,
defaut: DEFAUTS[nom], defaut,
genre: 'bascule', genre: 'bascule',
section: 'generation', 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({ const CIBLE = Object.freeze({
nom: 'cibleRedondance', nom: 'cibleRedondance',
cle: '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 * 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 * la recherche (§ 5.7, § 5.10), puis les six contraintes et l'attribution
* des sièges, dans l'ordre du § 5.3 ; enfin la cible de la somme de * des sièges, dans l'ordre du § 5.3 ; le mode des objectifs, que l'écran
* redondance (§ 8.9), de la section des propositions. * 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>} * @type {ReadonlyArray<EntreeReglage>}
*/ */
@ -95,7 +153,11 @@ export const REGLAGES_GENERATION = Object.freeze([
bascule('nouveauxVoisins'), bascule('nouveauxVoisins'),
bascule('nouvelleTable'), bascule('nouvelleTable'),
bascule('varierAppartenances'), bascule('varierAppartenances'),
bascule('respecterObjectifs', true),
bascule('rencontrerAnimateurs', false),
bascule('attribuerSieges'), bascule('attribuerSieges'),
MODE_OBJECTIFS,
PRIORITES,
CIBLE, 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é : * 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] * 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 {Object} reglages charge.reglages
* @param {EntreeReglage} entree * @param {EntreeReglage} entree
* @returns {number|boolean} * @returns {EntreeReglage['defaut']}
* @throws {RangeError} pour un genre inconnu * @throws {RangeError} pour un genre inconnu
*/ */
export function valeurDuReglage(reglages, entree) { 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)}`); throw new RangeError(`genre de réglage inconnu : ${JSON.stringify(entree.genre)}`);
} }
const valeur = entree.cle.split('.').reduce((objet, nom) => objet?.[nom], reglages); 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, // É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 // § 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 // défauts, ceux d'une charge neuve, GENERATION_PAR_DEFAUT et
// CIBLE_REDONDANCE_PAR_DEFAUT compris ; chaque réglage du schéma du fichier, // CIBLE_REDONDANCE_PAR_DEFAUT compris, et ceux que les lecteurs du stockage
// facultatif compris, et lui seul ; les clés que les commandes acceptent et // donnent aux réglages facultatifs — les deux cases, le mode des objectifs,
// les libellés qui existent ; la lecture d'une valeur dans une charge. // 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 assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js'; import { describe, test } from '../../../test/lanceur.js';
import { TABLES_APPLICATION } from '../../application/libelles.js'; import { TABLES_APPLICATION } from '../../application/libelles.js';
import { appliquerCommande } from '../../application/commandes.js'; import { appliquerCommande } from '../../application/commandes.js';
import { creerTraduction } from '../../application/traduction.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 { TABLES_INTERFACE } from '../libelles.js';
import { REGLAGES_GENERATION, reglagesDeSection, reglagesDuGenre, valeurDuReglage } from './reglages.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. // n'avoir aucune entrée : l'entrée qui s'ajoute l'en retire.
const SANS_ENTREE = new Map([ 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'], ['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([ 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) }]], ['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)', () => { 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(REGLAGES_GENERATION.length > 0, 'définition vide');
assert.ok(Object.isFrozen(REGLAGES_GENERATION)); assert.ok(Object.isFrozen(REGLAGES_GENERATION));
for (const entree of 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.ok(Object.isFrozen(entree), entree.nom);
} }
assert.throws(() => REGLAGES_GENERATION.push({}), TypeError); 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( assert.deepEqual(
REGLAGES_GENERATION.map(({ nom, genre, section }) => [section, genre, nom]), 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', 'nouveauxVoisins'],
['generation', 'bascule', 'nouvelleTable'], ['generation', 'bascule', 'nouvelleTable'],
['generation', 'bascule', 'varierAppartenances'], ['generation', 'bascule', 'varierAppartenances'],
['generation', 'bascule', 'respecterObjectifs'],
['generation', 'bascule', 'rencontrerAnimateurs'],
['generation', 'bascule', 'attribuerSieges'], ['generation', 'bascule', 'attribuerSieges'],
['generation', 'choix', 'modeObjectifs'],
['generation', 'ordre', 'priorites'],
['propositions', 'champ', 'cibleRedondance'], ['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']); assert.deepEqual(champs.map(({ unite }) => unite), ['propositions', 'mouvements', 'mouvements']);
const bascules = reglagesDuGenre(generation, 'bascule'); const bascules = reglagesDuGenre(generation, 'bascule');
assert.equal(bascules.length, 5); assert.equal(bascules.length, 7);
for (const { nom, cle, etiquette, unite } of bascules) { for (const { nom, cle, etiquette, unite } of bascules) {
assert.equal(cle, nom); assert.equal(cle, nom);
assert.equal(etiquette, `generation.contrainte.${nom}`); assert.equal(etiquette, `generation.contrainte.${nom}`);
assert.equal(unite, null); 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é', () => { 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( assert.deepEqual(
reglagesDuGenre(generation, 'bascule').map(({ defaut }) => defaut), 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', () => { 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', () => { test('chaque entrée s’écrit par la commande de sa section, et chaque libellé existe', () => {
let charge = chargeNeuveEpreuve(); let charge = chargeNeuveEpreuve();
for (const entree of REGLAGES_GENERATION) { 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); const [commande, args] = ECRITURES.get(entree.section)(entree, valeur);
charge = appliquerCommande(commande, charge, args).charge; 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.notEqual(t(entree.etiquette), entree.etiquette);
} }
assert.deepEqual([...new Set(REGLAGES_GENERATION.map(({ section }) => section))], [...ECRITURES.keys()]); 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'); const cible = REGLAGES_GENERATION.find(({ nom }) => nom === 'cibleRedondance');
assert.equal(valeurDuReglage({ generation: {} }, cible), CIBLE_REDONDANCE_PAR_DEFAUT); assert.equal(valeurDuReglage({ generation: {} }, cible), CIBLE_REDONDANCE_PAR_DEFAUT);
assert.equal(valeurDuReglage({ generation: {} }, { ...cible, defaut: 3 }), 3); 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', () => { test('un genre inconnu lève, et le nomme', () => {
@ -187,7 +281,7 @@ describe('valeurDuReglage, reglagesDuGenre et reglagesDeSection', () => {
); );
assert.deepEqual( assert.deepEqual(
reglagesDeSection(definition, 'generation').slice(0, 2).map(({ nom }) => nom), reglagesDeSection(definition, 'generation').slice(0, 2).map(({ nom }) => nom),
['attribuerSieges', 'varierAppartenances'], ['priorites', 'modeObjectifs'],
); );
assert.deepEqual(reglagesDuGenre([], 'bascule'), []); assert.deepEqual(reglagesDuGenre([], 'bascule'), []);
assert.deepEqual(reglagesDeSection([], 'generation'), []); assert.deepEqual(reglagesDeSection([], 'generation'), []);

View file

@ -2,14 +2,17 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) --> License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script> <script>
// Le contenu d'un aperçu d'import (§ 10.1, § 10.2), celui que rend // Le contenu de l'aperçu d'un import, § 10.1, § 10.2 et § 10.4, celui que
// modeleApercu (modele.js) : la source — le nom du fichier, ou le texte // rend modeleApercu (modele.js), de participants ou d'objectifs de table : la
// collé —, l'encodage et le séparateur nommés, le refus global avec sa // source — le nom du fichier, ou le texte collé —, l'encodage et le
// cause et son remède, l'association des colonnes et ses ambiguïtés, les // séparateur nommés, le refus global avec sa cause et son remède — texte,
// lignes valides, les lignes refusées et leur motif, les orthographes // ou commande du geste qui y mène —, l'association des colonnes et ses
// fondues, les doublons, les homonymes d'une mise à jour, les cellules sur // ambiguïtés, les lignes valides sous les colonnes de leur nature, les
// plusieurs lignes. Le rapport le reprend pour l'aperçu appliqué, ses // lignes refusées et leur motif, les orthographes fondues d'appartenances
// refus remplacés par ceux du résumé. // 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 } ; // modele : le modèle ; source : { origine: 'fichier'|'collage', nom } ;
// participants : ceux de la charge, qui nomment les personnes désignées ; // 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 // Chaque message est classé (Message, messageDuBloc) : le refus global et
// les lignes refusées en refus, les doublons, les homonymes et les // les lignes refusées en refus, les doublons, les homonymes et les
// cellules sur plusieurs lignes en avis, les orthographes fondues en // cellules sur plusieurs lignes en avis, les orthographes fondues,
// information. Les tableaux prennent le langage des cartes : en-tête sur // 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. // le fond sourd, lignes séparées d'un filet léger.
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { actionRemede } from '../primitives/commande.js';
import Commande from '../primitives/Commande.svelte';
import Pastille from '../primitives/Pastille.svelte'; import Pastille from '../primitives/Pastille.svelte';
import Message from './Message.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(); let { modele, source, participants = [], refusees = null, choisir = null } = $props();
const t = getContext('t'); const t = getContext('t');
const format = getContext('format'); const format = getContext('format');
const seance = getContext('seance');
const ui = getContext('ui') ?? null;
const motif = motifLigne(t); const motif = motifLigne(t);
const refuseesMontrees = $derived(refusees ?? modele.refusees); const refuseesMontrees = $derived(refusees ?? modele.refusees);
const global = $derived(modele.global); const global = $derived(modele.global);
const refusGlobal = messageDuBloc('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> </script>
<div class="apercu"> <div class="apercu">
@ -65,6 +74,9 @@
{#if !global.remede.dansLaCause} {#if !global.remede.dansLaCause}
<p>{t(global.remede.cle, global.remede.details)}</p> <p>{t(global.remede.cle, global.remede.details)}</p>
{/if} {/if}
{#if remedeGlobal !== null}
<Commande cle={remedeGlobal.cle} details={remedeGlobal.details} nom={remedeGlobal.nom} executer={remedeGlobal.executer} />
{/if}
</div> </div>
{/if} {/if}
@ -115,7 +127,7 @@
<thead> <thead>
<tr> <tr>
<th scope="col">{t('import.apercu.ligne')}</th> <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> <th scope="col">{t(cle)}</th>
{/each} {/each}
</tr> </tr>
@ -124,8 +136,18 @@
{#each modele.lignes as { ligne, cellules } (ligne)} {#each modele.lignes as { ligne, cellules } (ligne)}
<tr data-ligne={ligne}> <tr data-ligne={ligne}>
<th scope="row">{format.nombre(ligne)}</th> <th scope="row">{format.nombre(ligne)}</th>
{#each cellules as { champ, texte, cle } (champ)} {#each cellules as { champ, texte, cle, etiquettes } (champ)}
<td data-cellule={champ}>{cle === null ? (texte ?? '') : t(cle)}</td> <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} {/each}
</tr> </tr>
{/each} {/each}
@ -189,6 +211,27 @@
</Message> </Message>
{/if} {/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} {#if modele.doublons.length > 0}
<Message bloc="doublons" titre={t('import.apercu.doublons')}> <Message bloc="doublons" titre={t('import.apercu.doublons')}>
<ul data-doublons> <ul data-doublons>
@ -324,6 +367,13 @@
text-align: right; 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 { .defile {
max-height: 24rem; max-height: 24rem;
overflow: auto; overflow: auto;
@ -351,6 +401,26 @@
padding-left: var(--espace-5); 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 { .personne {
display: inline-flex; display: inline-flex;
gap: var(--espace-1); gap: var(--espace-1);

View file

@ -1,11 +1,13 @@
// © 2026 TechnoLibre (http://www.technolibre.ca) // © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // 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, // L'import des participants et des objectifs de table dans un vrai moteur de
// § 10.3, § 2.1 étape 2, § 9) : l'aperçu obligatoire, l'encodage et le // 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 // 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 // d'une ligne et les refus globaux, les étiquettes en pastilles et leurs
// modes, l'import selon l'état, le collage. Chaque épreuve monte App par // 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 // 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 // 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 // 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 { decoder } from '../../csv/encodage.js';
import { bilanRemplacement, exporterRefus as exporterRefusCsv } from '../../csv/import.js'; import { bilanRemplacement, exporterRefus as exporterRefusCsv } from '../../csv/import.js';
import { decouper } from '../../csv/lecture.js'; import { decouper } from '../../csv/lecture.js';
import { apercevoirObjectifs, bilanRemplacementObjectifs } from '../../csv/objectifs.js';
import { serialiser } from '../../stockage/canonique.js'; import { serialiser } from '../../stockage/canonique.js';
import { creerCharge } from '../../stockage/document.js'; import { creerCharge } from '../../stockage/document.js';
import { lireJournal } from '../../stockage/journal.js'; import { lireJournal } from '../../stockage/journal.js';
import { SUFFIXES } from '../../stockage/noms.js'; import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.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 { monterApp } from '../../../test/navigateur/montage.js';
import { composantes, visible } from '../../../test/navigateur/rendu.js'; import { composantes, visible } from '../../../test/navigateur/rendu.js';
import urlCasLimites from '../../../exemples/participants_cas_limites.csv?url'; import urlCasLimites from '../../../exemples/participants_cas_limites.csv?url';
import urlCasLimites1252 from '../../../exemples/participants_cas_limites_cp1252.csv?url'; import urlCasLimites1252 from '../../../exemples/participants_cas_limites_cp1252.csv?url';
import urlGrande from '../../../exemples/participants_demo_grande.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 { TABLES_INTERFACE } from '../libelles.js';
import { ENCODAGES, SEPARATEURS } from './modele.js'; import { ENCODAGES, SEPARATEURS } from './modele.js';
@ -93,10 +97,12 @@ afterEach(() => {
* dialogue de choix sert aServir dans l'ordre ; l'enregistreur relève * dialogue de choix sert aServir dans l'ordre ; l'enregistreur relève
* enregistres et rend le nom proposé. * 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 }); const charge = creerCharge({ id: 'epreuve-import', nom: 'Soirée des Merles', siegesParDefaut, tours: 2 });
charge.participants = participants; charge.participants = participants;
charge.prochainsIds.participant = participants.length + 1; 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 texte = serialiser(charge, { revision: 1, produitVersion: VERSION.affichee });
const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `${BASE}.gtt.json`, texte }], langue }); const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `${BASE}.gtt.json`, texte }], langue });
montes.push(m); 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 commande = (e, nom) => dans(e, `[data-commande="${nom}"]`);
const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim(); const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
const ecritures = ({ m }) => m.trace.length; 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. // Sert un fichier au dialogue et le choisit : l'aperçu s'ouvre sur lui.
async function choisir(e, nom, octets) { async function choisir(e, nom, octets) {
@ -146,13 +152,13 @@ function choisirMode(e, mode) {
flushSync(); flushSync();
} }
// Clique « Importer » et attend la fin de l'appel à la séance qu'il fait : // Clique « Importer » et attend la fin de l'appel à la séance qu'il fait,
// rend ce que rend seance.executer, ou le refus. // la commande nom : rend ce que rend seance.executer, ou le refus.
async function importer(e) { async function importer(e, nom = 'importerParticipants') {
const avant = imports(e).length; const avant = imports(e, nom).length;
commande(e, 'importer').click(); commande(e, 'importer').click();
await vi.waitFor(() => expect(imports(e).length).toBe(avant + 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] === 'importerParticipants').at(-1); const resultat = e.espion.mock.results.filter((_, rang) => e.espion.mock.calls[rang][0] === nom).at(-1);
try { try {
const rendu = await resultat.value; const rendu = await resultat.value;
await vi.waitFor(() => expect(dans(e, '[data-rapport]')).not.toBeNull()); 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 // 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) => const lignesMontrees = (conteneur) =>
[...conteneur.querySelectorAll('[data-lignes] [data-ligne]')].map((ligne) => [...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) => 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 refusee = (conteneur, ligne) => conteneur.querySelector(`[data-refusees] [data-ligne="${ligne}"]`);
const association = (conteneur) => const association = (conteneur) =>
[...conteneur.querySelectorAll('[data-colonne]')].map((colonne) => [ [...conteneur.querySelectorAll('[data-colonne]')].map((colonne) => [
@ -213,6 +225,17 @@ function vu(element) {
return visible(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)', () => { 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 () => { test('choisir les cas limites ouvre l’aperçu, sans rien écrire ; déposer un fichier sur la fenêtre ouvre son aperçu ; aucun contrôle hors de l’import n’importe', async () => {
const e = await ouvrir(); const 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', () => { 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'))); const nomDe = (region) => texteDe(document.getElementById(region.getAttribute('aria-labelledby')));
// La classe d'un message, lue dans son bloc : la pastille dont le texte // 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-refusees]'), 'refus', `${lieu}, lignes refusées`);
exigerSorte(bloc('[data-doublons]'), 'avis', `${lieu}, doublons`); exigerSorte(bloc('[data-doublons]'), 'avis', `${lieu}, doublons`);
exigerSorte(bloc('[data-fusions]'), 'information', `${lieu}, orthographes fondues`); 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(); const e = await ouvrir();
await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites)); await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites));
exigerBlocs(dans(e, '[data-apercu]'), 'aperçu'); 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'); 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 () => { test('un refus global porte la pastille du refus, sa cause et son remède', async () => {
const e = await ouvrir(); const e = await ouvrir();
await coller(e, 'Ombrelle\tIris\nPervenche\tThéo'); 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')); 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) --> License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script> <script>
// Le rapport d'un import appliqué (§ 10.1) : les comptes du résumé que // Le rapport d'un import appliqué (§ 10.1, § 10.4) : les comptes du résumé
// rend seance.executer — ajoutés, mis à jour, inchangés, retirés, // que rend seance.executer, ceux de sa nature (comptesDuRapport) — pour des
// réservations retirées —, puis l'aperçu appliqué, ses lignes refusées // participants, ajoutés, mis à jour, inchangés, retirés, réservations
// remplacées par resume.refusees : celles de l'aperçu, puis celles de la // retirées ; pour des objectifs, ajoutés, remplacés, inchangés, retirés,
// mise à jour, chacune à son rang, avec les personnes qu'elle désigne. Un // tables dont les objectifs changent —, puis l'aperçu appliqué, ses lignes
// import qui n'a rien écrit le dit. // 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 // 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 // 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 // information (Message). Ses commandes, celles de la section, viennent
// en pied de carte (commandes, un snippet). // en pied de carte (commandes, un snippet).
// //
// rapport : { modele, source, resume, libelle } ; participants : ceux de // rapport : { nature, modele, source, resume, libelle } ; participants :
// la charge écrite. // ceux de la charge écrite.
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import Carte from '../primitives/Carte.svelte'; import Carte from '../primitives/Carte.svelte';
import Apercu from './Apercu.svelte'; import Apercu from './Apercu.svelte';
import Message from './Message.svelte'; import Message from './Message.svelte';
import { refuseesDe } from './modele.js'; import { comptesDuRapport, refuseesDe } from './modele.js';
let { rapport, participants, commandes = null } = $props(); let { rapport, participants, commandes = null } = $props();
const t = getContext('t'); const t = getContext('t');
const format = getContext('format'); const format = getContext('format');
// Chaque compte du résumé et la clé de son libellé, dans l'ordre du résumé. // Ce qu'un import qui n'a rien écrit dit, par nature.
const COMPTES = [ const RIEN_ECRIT = new Map([
['ajoutes', 'import.rapport.ajoutes'], ['participants', 'import.rapport.rienEcrit'],
['misAJour', 'import.rapport.misAJour'], ['objectifs', 'import.rapport.rienEcritObjectifs'],
['inchanges', 'import.rapport.inchanges'], ]);
['retires', 'import.rapport.retires'],
['reservationsRetirees', 'import.rapport.reservationsRetirees'],
];
const comptes = $derived(comptesDuRapport(rapport.nature));
const refusees = $derived(refuseesDe(rapport.resume.refusees)); const refusees = $derived(refuseesDe(rapport.resume.refusees));
</script> </script>
@ -43,10 +43,10 @@
<Carte titre={t('import.rapport.titre')} niveau={3}> <Carte titre={t('import.rapport.titre')} niveau={3}>
<div class="contenu"> <div class="contenu">
{#if rapport.libelle === null} {#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} {/if}
<ul class="comptes"> <ul class="comptes">
{#each COMPTES as [nom, cle] (nom)} {#each comptes as { nom, cle } (nom)}
<li data-compte={nom}> <li data-compte={nom}>
<span class="nombre" data-nombre>{format.nombre(rapport.resume[nom])}</span> <span class="nombre" data-nombre>{format.nombre(rapport.resume[nom])}</span>
<span>{t(cle, { n: 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) --> License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script> <script>
// La section de l'import des participants (§ 10.1, § 10.2, § 2.1 étape // La section d'un import, § 10.1, § 10.2, § 10.4 et § 2.1 étape 2 : des
// 2). Un fichier choisi par le dialogue de la façade, un fichier déposé // participants, ou des objectifs de table, selon la nature que
// sur la fenêtre, ou un texte que le collage de la grille remet par // l'opérateur coche avant l'aperçu — Participants par défaut. La nature
// ui.ouvrirImport, ouvre l'aperçu ; rien ne s'importe hors de lui. // ne se devine jamais de l'en-tête : un fichier lu sous la mauvaise se
// L'aperçu se calcule par apercevoir contre les participants et les // refuse, et l'opérateur la change. Un fichier choisi par le dialogue de
// tables de la charge — les étiquettes des objectifs donnent leur // la façade, un fichier déposé sur la fenêtre, ou un texte que le collage
// orthographe à celles du fichier —, sous le mode choisi et le choix des // de la grille remet par ui.ouvrirImport, ouvre l'aperçu ; rien ne
// colonnes ambiguës : il n'écrit rien, et s'ouvre en lecture comme sur un // s'importe hors de lui. Le collage vient de la grille des participants,
// plan bloqué. Seule // et coche leur nature.
// « Importer » écrit, par seance.executer('importerParticipants', …), et //
// son refus se pose à côté d'elle ; « Remplacer » passe d'abord par la // L'aperçu se calcule par apercevoirSelon (modele.js) contre la charge,
// fenêtre à confirmer, qui porte les quatre comptes de bilanRemplacement, // sous la nature, le mode choisi et le choix des colonnes ambiguës : il
// et ne l'ouvre que sur un import que la commande accepterait. // n'écrit rien, et s'ouvre en lecture comme sur un plan bloqué. Changer
// Le rapport lit le résumé que la séance rend. // 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 // 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 // « Choisir un fichier CSV… », son action principale, et dont le contenu
// rapport, sont des cartes sous elle. // 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 // 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 // d'exporterRefus de src/application/csv.js, dont le motif est celui de
@ -28,13 +36,27 @@
// et corrige le fichier. // et corrige le fichier.
import { getContext, untrack } from 'svelte'; import { getContext, untrack } from 'svelte';
import { appliquerCommande } from '../../application/commandes.js'; 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 Carte from '../primitives/Carte.svelte';
import Commande from '../primitives/Commande.svelte'; import Commande from '../primitives/Commande.svelte';
import Confirmation from '../primitives/Confirmation.svelte'; import Confirmation from '../primitives/Confirmation.svelte';
import Apercu from './Apercu.svelte'; import Apercu from './Apercu.svelte';
import Rapport from './Rapport.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 t = getContext('t');
const seance = getContext('seance'); const seance = getContext('seance');
@ -45,38 +67,55 @@
// La source de l'aperçu : { origine: 'fichier'|'collage', nom, encodage, // La source de l'aperçu : { origine: 'fichier'|'collage', nom, encodage,
// texte, refusDecodage } ; texte null sous un refus de decoder. // texte, refusDecodage } ; texte null sous un refus de decoder.
let source = $state.raw(null); 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. // { champ: rang } qui tranche une ambiguïté de l'en-tête.
let choix = $state.raw({}); let choix = $state.raw({});
let mode = $state('ajouter'); 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); 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); let confirmation = $state.raw(null);
// Le chemin où le CSV des refus vient d'être enregistré. // Le chemin où le CSV des refus vient d'être enregistré.
let enregistre = $state(null); let enregistre = $state(null);
const participants = $derived($seance.charge?.participants ?? []); const charge = $derived($seance.charge ?? null);
const tables = $derived($seance.charge?.tables ?? []); const participants = $derived(charge?.participants ?? []);
const apercu = $derived( 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( 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. // 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 // Ce qui suit un aperçu neuf : le choix des colonnes repart de rien, le
// proposé suit la liste, le rapport précédent se retire. // mode proposé suit la nature et la liste, le rapport précédent se
function ouvrirSource(nouvelle) { // retire.
source = nouvelle; function repartir() {
choix = {}; choix = {};
mode = modePropose(untrack(() => participants)); mode = modePropose(untrack(() => participants), untrack(() => nature));
rapport = null; rapport = null;
confirmation = null; confirmation = null;
enregistre = 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, // 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. // qui tient lieu d'aperçu. Toute autre erreur est une faute, qui remonte.
function lireFichier({ nom, octets }) { function lireFichier({ nom, octets }) {
@ -113,7 +152,9 @@
if ($ui.importEnAttente === null) return; if ($ui.importEnAttente === null) return;
untrack(() => { untrack(() => {
const pris = ui.prendreImport(); 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; enregistre = null;
} }
// L'aperçu appliqué par la séance, dans son mode ; le rapport lit le // L'aperçu appliqué par la séance, par la commande de sa nature, dans son
// résumé rendu, et l'aperçu se ferme. // mode ; le rapport lit le résumé rendu, et l'aperçu se ferme.
async function appliquer(applique, sonMode, depuis) { async function appliquer(applique, sonMode, depuis, saNature) {
const { libelle, resume } = await seance.executer('importerParticipants', { apercu: applique, mode: sonMode }); const { libelle, resume } = await seance.executer(COMMANDES_DES_NATURES.get(saNature), { apercu: applique, mode: sonMode });
rapport = { 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 }, source: { origine: depuis.origine, nom: depuis.nom },
apercu: applique, apercu: applique,
resume, resume,
@ -159,17 +201,32 @@
const applique = apercu; const applique = apercu;
const sonMode = mode; const sonMode = mode;
const depuis = source; const depuis = source;
const saNature = nature;
if (sonMode !== 'remplacer') { if (sonMode !== 'remplacer') {
await appliquer(applique, sonMode, depuis); await appliquer(applique, sonMode, depuis, saNature);
return; return;
} }
const { refus, charge } = seance.etat(); const { refus, charge: courante } = seance.etat();
if (refus !== null) throw refus; if (refus !== null) throw refus;
appliquerCommande('importerParticipants', charge, { apercu: applique, mode: sonMode }); appliquerCommande(COMMANDES_DES_NATURES.get(saNature), courante, { apercu: applique, mode: sonMode });
const bilan = bilanRemplacement(charge); confirmation = { nature: saNature, apercu: applique, depuis, ...annonceDuRemplacement(saNature, courante) };
confirmation = { }
apercu: applique,
depuis, // 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: [ elements: [
t('import.remplacer.participants', { n: bilan.participants }), t('import.remplacer.participants', { n: bilan.participants }),
t('import.remplacer.reservations', { n: bilan.reservations }), t('import.remplacer.reservations', { n: bilan.reservations }),
@ -180,7 +237,7 @@
} }
async function confirmerRemplacement() { async function confirmerRemplacement() {
await appliquer(confirmation.apercu, 'remplacer', confirmation.depuis); await appliquer(confirmation.apercu, 'remplacer', confirmation.depuis, confirmation.nature);
confirmation = null; confirmation = null;
} }
@ -197,6 +254,21 @@
{#snippet actions()} {#snippet actions()}
<Commande cle="import.commande.choisirFichier" nom="choisirFichier" variante="principale" executer={choisirFichier} /> <Commande cle="import.commande.choisirFichier" nom="choisirFichier" variante="principale" executer={choisirFichier} />
{/snippet} {/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> </Carte>
{#if source !== null} {#if source !== null}
@ -207,7 +279,7 @@
{#if modele.global === null} {#if modele.global === null}
<fieldset class="modes" data-champ="mode"> <fieldset class="modes" data-champ="mode">
<legend>{t('import.mode.titre')}</legend> <legend>{t('import.mode.titre')}</legend>
{#each MODES_D_IMPORT as { mode: valeur, cle } (valeur)} {#each modesDImport(nature) as { mode: valeur, cle } (valeur)}
<label> <label>
<input type="radio" name={`${uid}-mode`} value={valeur} bind:group={mode} /> <input type="radio" name={`${uid}-mode`} value={valeur} bind:group={mode} />
<span>{t(cle)}</span> <span>{t(cle)}</span>
@ -265,7 +337,7 @@
{#if confirmation !== null} {#if confirmation !== null}
<Confirmation <Confirmation
titre="import.remplacer.titre" titre={confirmation.titre}
elements={confirmation.elements} elements={confirmation.elements}
surConfirmer={confirmerRemplacement} surConfirmer={confirmerRemplacement}
surAnnuler={() => (confirmation = null)} surAnnuler={() => (confirmation = null)}
@ -297,7 +369,8 @@
gap: var(--espace-2); gap: var(--espace-2);
} }
.modes { .modes,
.natures {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--espace-3); gap: var(--espace-3);
@ -312,4 +385,11 @@
padding: 0 var(--espace-1); padding: 0 var(--espace-1);
font-weight: bold; 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> </style>

View file

@ -1,13 +1,39 @@
// © 2026 TechnoLibre (http://www.technolibre.ca) // © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // 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, // Le modèle de l'aperçu d'un import, § 10.1, § 10.2 et § 10.4 : ce que
// en clés de libellés et en données, d'un fichier décodé, d'un fichier que // l'aperçu montre, en clés de libellés et en données, d'un fichier décodé,
// decoder refuse, ou d'un collage. Le texte vient de la traduction ; ce // d'un fichier que decoder refuse, ou d'un collage, sous la nature que
// module ne choisit que les clés, et ne recalcule rien de ce que l'aperçu de // l'opérateur a choisie — des participants ou des objectifs de table. Le
// src/csv établit : association, lignes, fusions et doublons s'y lisent tels // texte vient de la traduction ; ce module ne choisit que les clés, et ne
// quels. // recalcule rien de ce que l'aperçu de src/csv établit : association,
import { CHAMPS, JOINTURE_ETIQUETTES } from '../../application/csv.js'; // 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 /** La clé du nom de chaque encodage que rend decoder, dans l'ordre de ses
* quatre temps. */ * quatre temps. */
@ -28,7 +54,8 @@ export const SEPARATEURS = new Map([
[null, 'import.separateur.uneColonne'], [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([ const CLES_DES_CHAMPS = new Map([
['nom', 'import.champ.nom'], ['nom', 'import.champ.nom'],
['prenom', 'import.champ.prenom'], ['prenom', 'import.champ.prenom'],
@ -38,6 +65,18 @@ const CLES_DES_CHAMPS = new Map([
['exclu', 'import.champ.exclu'], ['exclu', 'import.champ.exclu'],
['notes', 'import.champ.notes'], ['notes', 'import.champ.notes'],
['etiquettes', 'import.champ.etiquettes'], ['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. // 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. // Le nom de chaque champ dans ChampsLus, celui de l'aperçu de src/csv.
const CHAMPS_LUS = new Map([['titre_pressenti', 'titrePressenti']]); const CHAMPS_LUS = new Map([['titre_pressenti', 'titrePressenti']]);
/** Les trois modes de l'import (§ 10.1), dans l'ordre où l'écran les // Des modes et la clé de leur nom, figés.
* offre, et la clé de leur nom. */ const modes = (paires) => Object.freeze(paires.map(([mode, cle]) => Object.freeze({ mode, cle })));
export const MODES_D_IMPORT = Object.freeze(
[ /** Les trois modes de l'import des participants (§ 10.1), dans l'ordre où
['ajouter', 'import.mode.ajouter'], * l'écran les offre, et la clé de leur nom. */
['mettreAJour', 'import.mode.mettreAJour'], export const MODES_D_IMPORT = modes([
['remplacer', 'import.mode.remplacer'], ['ajouter', 'import.mode.ajouter'],
].map(([mode, cle]) => Object.freeze({ mode, cle })), ['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 /** Les trois sortes d'un message de l'aperçu et du rapport, chacune un ton
* de pastille (Pastille, sorte 'statut'). */ * 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 // que le logiciel ne fusionne jamais seul — doublons, homonymes d'une mise
// à jour —, ou une cellule sur plusieurs lignes qui porte le séparateur, // à 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 // 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([ const SORTES_DES_BLOCS = new Map([
['global', 'refus'], ['global', 'refus'],
['refusees', 'refus'], ['refusees', 'refus'],
@ -84,6 +164,7 @@ const SORTES_DES_BLOCS = new Map([
['homonymes', 'avis'], ['homonymes', 'avis'],
['multilignes', 'avis'], ['multilignes', 'avis'],
['fusions', 'information'], ['fusions', 'information'],
['fusionsEtiquettes', 'information'],
['rienEcrit', '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. * La classe d'un bloc de messages de l'aperçu ou du rapport.
* *
* @param {string} bloc 'global', 'refusees', 'doublons', 'homonymes', * @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é * @returns {{sorte: string, cle: string, icone: string}} la sorte, la clé
* de son libellé et l'icône de sa pastille * de son libellé et l'icône de sa pastille
* @throws {RangeError} un bloc inconnu * @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) }; return { sorte, cle: `import.sorte.${sorte}`, icone: ICONES_DE_SORTE.get(sorte) };
} }
/** Les colonnes du tableau des lignes : chaque champ, dans l'ordre de // Des champs et la clé de leur nom, figés.
* CHAMPS, et la clé de son nom. */ const colonnes = (champs) => Object.freeze(champs.map((champ) => Object.freeze({ champ, cle: CLES_DES_CHAMPS.get(champ) })));
export const COLONNES_DES_LIGNES = 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 // 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 »). // 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']); 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 // La clé du remède de chaque refus global de l'aperçu des participants. Un
// arrive que d'un texte collé : celui d'un fichier, decoder le refuse. // 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([ const REMEDES_DE_L_APERCU = new Map([
['CARACTERE_NUL', 'import.remede.caractereNulCollage'], ['CARACTERE_NUL', 'import.remede.caractereNulCollage'],
['AUCUN_ENTETE', 'import.remede.aucunEntete'], ['AUCUN_ENTETE', 'import.remede.aucunEntete'],
@ -121,6 +247,26 @@ const REMEDES_DE_L_APERCU = new Map([
['SEPARATEUR_INTROUVABLE', 'import.remede.separateurIntrouvable'], ['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. // Le remède d'un collage auquel l'aperçu offre l'ordre de la grille.
const REMEDES_DE_LA_GRILLE = new Map([ const REMEDES_DE_LA_GRILLE = new Map([
['AUCUN_ENTETE', 'import.remede.aucunEnteteCollage'], ['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 // 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. // non blanc sans en-tête reconnu, ou une seule colonne sans séparateur, lu
function offreLaGrille(apercu) { // 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; if (apercu.refusGlobal === 'AUCUN_ENTETE') return apercu.entetes.length > 0;
return apercu.refusGlobal === 'SEPARATEUR_INTROUVABLE' && apercu.refusGlobalLigne === null; 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. // le code de details.remede, déjà dans la cause.
function globalDeDecoder({ code, details }) { function globalDeDecoder({ code, details }) {
if (!REFUS_DE_DECODER.has(code)) throw new RangeError(`import : refus de decoder inconnu ${JSON.stringify(code)}`); 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 // 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. // offre l'ordre de la grille prend le remède qui le nomme. Un refus que son
function globalDeLApercu(apercu, collage) { // texte remédie déjà porte le seul geste qui y mène.
function globalDeLApercu(apercu, collage, nature) {
const code = apercu.refusGlobal; const code = apercu.refusGlobal;
if (code === null) return null; 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 rang = apercu.refusGlobalLigne;
const details = rang === null ? {} : { ligne: rang }; 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 // 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 // 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 // clé de ce qu'elle devient — un champ de la nature, une colonne à trancher,
// écartée par le choix d'une autre, une colonne non reconnue. // une colonne écartée par le choix d'une autre, une colonne non reconnue.
function colonnesDe({ entetes, association }) { function colonnesDe({ entetes, association }, champs) {
const champDuRang = new Map(CHAMPS.filter((champ) => Object.hasOwn(association.colonnes, champ)).map((champ) => [association.colonnes[champ], champ])); 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 aTrancher = new Set(association.ambigus.flatMap(({ rangs }) => rangs));
const nonReconnues = new Set(association.nonReconnues); const nonReconnues = new Set(association.nonReconnues);
return entetes.map((entete, rang) => { 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 // Une cellule de l'aperçu : un texte lu, une clé de libellé, ou une liste
// de CHAMPS : le texte lu, ou la clé de la valeur d'« exclu » ; les // d'étiquettes, chacune une pastille ; les deux autres null.
// étiquettes jointes comme l'export les joint, null sans étiquette. const cellule = (champ, { texte = null, cle = null, etiquettes = null }) => ({ champ, texte, cle, etiquettes });
function lignesDe(lignes) {
// 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 }) => ({ return lignes.map(({ ligne, champs }) => ({
ligne, ligne,
cellules: CHAMPS.map((champ) => { cellules: CHAMPS.map((champ) => {
const valeur = champs[CHAMPS_LUS.get(champ) ?? 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 === 'exclu') return cellule(champ, { cle: valeur === null ? null : CLES_D_EXCLU.get(valeur) });
if (champ === 'etiquettes') return { champ, texte: valeur === null ? null : valeur.join(JOINTURE_ETIQUETTES), cle: null }; if (champ === 'etiquettes') return cellule(champ, { etiquettes: valeur ?? [] });
return { champ, texte: valeur, cle: null }; 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 * 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 * 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 * Le mode d'import proposé (§ 10.1, § 10.4) : pour des participants, Ajouter
* participant, Mettre à jour sinon. Remplacer ne se propose jamais : il se * dans un événement sans participant, Mettre à jour sinon ; pour des
* choisit. * objectifs, Mettre à jour. Remplacer ne se propose jamais : il se choisit.
* *
* @param {Array<Object>} participants * @param {Array<Object>} participants
* @param {'participants'|'objectifs'} [nature] participants par défaut
* @returns {'ajouter'|'mettreAJour'} * @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'; return participants.length === 0 ? 'ajouter' : 'mettreAJour';
} }
@ -252,6 +428,7 @@ const RIEN = Object.freeze({
lignes: [], lignes: [],
refusees: [], refusees: [],
fusions: [], fusions: [],
fusionsEtiquettes: [],
doublons: [], doublons: [],
homonymes: [], homonymes: [],
multilignes: [], 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 * Ce que l'aperçu montre, d'un fichier ou d'un collage, sous sa nature :
* un collage) et séparateur nommés — « une seule colonne » pour un * encodage (null pour un collage) et séparateur nommés — « une seule
* séparateur null —, association par colonne, ambiguïtés, lignes valides et * colonne » pour un séparateur null —, association par colonne, ambiguïtés,
* refusées, fusions, doublons, et le refus global, de decoder (refusDecodage, * colonnes et lignes valides, lignes refusées, orthographes fondues
* apercu null) ou de l'aperçu (apercu.refusGlobal), avec la clé de sa cause * d'appartenances et d'étiquettes, chacune dans son bloc, doublons, et le
* et celle de son remède — en clés de libellés et en données. * 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 * 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 * 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 * 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 * encodage : celui que rend decoder, null pour un collage
* @returns {{ * @returns {{
* encodage: string|null, separateur: string|null, * encodage: string|null, separateur: string|null,
* champs: ReadonlyArray<{champ: string, cle: string}>,
* colonnes: Array<{rang: number, entete: string, cle: string}>, * colonnes: Array<{rang: number, entete: string, cle: string}>,
* ambigus: Array<{champ: string, cle: string, rangs: Array<{rang: number, entete: string}>}>, * ambigus: Array<{champ: string, cle: string, rangs: Array<{rang: number, entete: string}>}>,
* valides: number, * 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}>, * refusees: Array<{ligne: number, code: string, details: Object}>,
* fusions: Array<{affichee: string, orthographes: Array<{texte: string, lignes: number}>}>, * fusions: Array<{affichee: string, orthographes: Array<{texte: string, lignes: number}>}>,
* fusionsEtiquettes: Array<{retenue: string, orthographes: string[], lignes: number}>,
* doublons: Array<{lignes: number[], existants: number[]}>, * doublons: Array<{lignes: number[], existants: number[]}>,
* homonymes: Array<{ligne: number, participants: number[]}>, * homonymes: Array<{ligne: number, participants: number[]}>,
* multilignes: Array<{ligne: number, rangs: 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 * champs : les colonnes du tableau des lignes de la nature ; encodage,
* traduction, comme lignes[].cellules[].cle, celle de la valeur * separateur, colonnes[].cle et ambigus[].cle sont des clés de
* d'« exclu » ; refusees[].details, ceux que reçoit le motif d'une ligne * traduction, comme lignes[].cellules[].cle — la valeur d'« exclu », la
* (motifLigne) ; fusions[].orthographes[].lignes, le nombre de lignes de * soirée, la règle d'un objectif ; refusees[].details, ceux que reçoit le
* l'orthographe, 0 pour celle que seuls des participants existants portent * motif d'une ligne (motifLigne) ; fusions[].orthographes[].lignes, le
* @throws {RangeError} un encodage, un séparateur ou un refus sans clé * 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 }) { export function modeleApercu({ nature = 'participants', encodage = null, apercu = null, refusDecodage = null }) {
if (refusDecodage !== null) return { ...RIEN, global: globalDeDecoder(refusDecodage) }; const objectifs = exigerNature(nature) === 'objectifs';
if (apercu === null) return { ...RIEN }; 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; const { entetes, association } = apercu;
return { return {
encodage: encodage === null ? null : dans(ENCODAGES, encodage, 'encodage'), encodage: encodage === null ? null : dans(ENCODAGES, encodage, 'encodage'),
separateur: separateurDe(apercu), separateur: separateurDe(apercu),
colonnes: colonnesDe(apercu), champs,
colonnes: colonnesDe(apercu, objectifs ? CHAMPS_OBJECTIFS : CHAMPS),
ambigus: association.ambigus.map(({ champ, rangs }) => ({ ambigus: association.ambigus.map(({ champ, rangs }) => ({
champ, champ,
cle: CLES_DES_CHAMPS.get(champ), cle: CLES_DES_CHAMPS.get(champ),
rangs: rangs.map((rang) => ({ rang, entete: entetes[rang] })), rangs: rangs.map((rang) => ({ rang, entete: entetes[rang] })),
})), })),
valides: apercu.lignes.length, valides: apercu.lignes.length,
lignes: lignesDe(apercu.lignes), lignes: objectifs ? lignesDesObjectifs(apercu.lignes) : lignesDesParticipants(apercu.lignes),
refusees: refuseesDe(apercu.refusees), refusees: refuseesDe(apercu.refusees),
fusions: apercu.fusions.map(({ affichee, orthographes }) => ({ fusions: (apercu.fusions ?? []).map(({ affichee, orthographes }) => ({
affichee, affichee,
orthographes: orthographes.map(({ texte, lignes }) => ({ texte, lignes: lignes.length })), orthographes: orthographes.map(({ texte, lignes }) => ({ texte, lignes: lignes.length })),
})), })),
doublons: apercu.doublons.map(({ lignes, existants }) => ({ lignes, existants })), fusionsEtiquettes: apercu.fusionsEtiquettes.map(({ retenue, orthographes, lignes }) => ({
homonymes: apercu.homonymes, retenue,
orthographes: [...orthographes],
lignes,
})),
doublons: (apercu.doublons ?? []).map(({ lignes, existants }) => ({ lignes, existants })),
homonymes: apercu.homonymes ?? [],
multilignes: apercu.multilignes, 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 —, * 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 * 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 * blanc, largeur vaut apercu.entetes.length ; sous SEPARATEUR_INTROUVABLE
* sans candidat (refusGlobalLigne null : une seule colonne), largeur vaut 1 * sans candidat (refusGlobalLigne null : une seule colonne), largeur vaut 1
* et la ligne est « nom ». null sous tout autre aperçu. Offert pour un * 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 * 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 {string} texte le texte de l'aperçu
* @param {Object} apercu l'aperçu de ce texte * @param {Object} apercu l'aperçu de ce texte
* @param {'participants'|'objectifs'} [nature] participants par défaut
* @returns {string|null} * @returns {string|null}
*/ */
export function avecEntetesDeLaGrille(texte, apercu) { export function avecEntetesDeLaGrille(texte, apercu, nature = 'participants') {
if (!offreLaGrille(apercu)) return null; if (!offreLaGrille(apercu, exigerNature(nature))) return null;
const largeur = apercu.refusGlobal === 'AUCUN_ENTETE' ? apercu.entetes.length : 1; const largeur = apercu.refusGlobal === 'AUCUN_ENTETE' ? apercu.entetes.length : 1;
const entetes = Array.from({ length: largeur }, (_, rang) => CHAMPS[rang] ?? ''); const entetes = Array.from({ length: largeur }, (_, rang) => CHAMPS[rang] ?? '');
return `${entetes.join(apercu.separateur ?? '')}\n${texte}`; return `${entetes.join(apercu.separateur ?? '')}\n${texte}`;

View file

@ -1,27 +1,35 @@
// © 2026 TechnoLibre (http://www.technolibre.ca) // © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // 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 // Le modèle de l'aperçu d'un import, § 10.1, § 10.2 et § 10.4 : la lecture
// sans en-tête dans l'ordre de la grille, la clé de la cause et celle du // d'un collage sans en-tête dans l'ordre de la grille, la clé de la cause et
// remède de chaque refus global, l'encodage et le séparateur nommés, et les // celle du remède de chaque refus global, l'encodage et le séparateur
// titres que « Remplacer » annonce, comptés par la règle unique des titres // nommés, les titres que « Remplacer » annonce, comptés par la règle unique
// pourvus. Les noms d'épreuve sont inventés. // 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 assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js'; import { describe, test } from '../../../test/lanceur.js';
import { TABLES_APPLICATION } from '../../application/libelles.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 { comptesTitres, titresNonPourvus } from '../../application/listes_travail.js';
import { creerTraduction } from '../../application/traduction.js'; import { creerTraduction } from '../../application/traduction.js';
import { creerCharge } from '../../stockage/document.js'; import { creerCharge } from '../../stockage/document.js';
import { TABLES_INTERFACE } from '../libelles.js'; import { TABLES_INTERFACE } from '../libelles.js';
import { import {
COLONNES_DES_LIGNES, COLONNES_DES_LIGNES,
COLONNES_DES_OBJECTIFS,
ENCODAGES, ENCODAGES,
MODES_D_IMPORT, MODES_D_IMPORT,
NATURES_D_IMPORT,
NATURE_PAR_DEFAUT,
SEPARATEURS, SEPARATEURS,
SORTES_DE_MESSAGE, SORTES_DE_MESSAGE,
apercevoirSelon,
avecEntetesDeLaGrille, avecEntetesDeLaGrille,
comptesDuRapport,
messageDuBloc, messageDuBloc,
modesDImport,
modeleApercu, modeleApercu,
modePropose, modePropose,
motifLigne, 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)', () => { 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( const texte = lignes(
'notes;exclu;nom;prenom;mots-clés', 'notes;exclu;nom;prenom;mots-clés',
'arrive tard;Oui;Ombrelle;Iris;', '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); for (const { cle } of COLONNES_DES_LIGNES) assert.ok(t(cle).length > 0, cle);
const [premiere, deuxieme, troisieme] = lues; const [premiere, deuxieme, troisieme] = lues;
assert.deepEqual(premiere.cellules.map(({ texte: valeur }) => valeur), ['Ombrelle', 'Iris', null, null, null, null, 'arrive tard', null]); 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(premiere.cellules[5].cle, 'import.exclu.oui');
assert.equal(deuxieme.cellules[5].cle, null); assert.equal(deuxieme.cellules[5].cle, null);
assert.equal(troisieme.cellules[5].cle, 'import.exclu.non'); 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', () => { test('les informations : les orthographes fondues, d’appartenance ou d’étiquette, un import qui n’a rien écrit', () => {
for (const bloc of ['fusions', 'rienEcrit']) { for (const bloc of ['fusions', 'fusionsEtiquettes', 'rienEcrit']) {
assert.deepEqual(messageDuBloc(bloc), { sorte: 'information', cle: 'import.sorte.information', icone: 'information' }, bloc); 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', () => { test('trois sortes, chacune un ton de pastille, un libellé traduit et une icône distincte', () => {
assert.deepEqual(SORTES_DE_MESSAGE, ['refus', 'avis', 'information']); 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.equal(new Set(blocs.map(({ icone }) => icone)).size, 3);
assert.deepEqual( assert.deepEqual(
SORTES_DE_MESSAGE.map((sorte) => t(`import.sorte.${sorte}`)), 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`, 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) --------------------- // --- Le plafond a priori et les imposés (§ 5.4, § 5.5) ---------------------
[ [
'diagnostic.plafond', '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.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.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'], ['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) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les libellés de la génération : meilleure proposition du classement, // 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 // réglages, mode des objectifs, ordre des priorités, cœurs de calcul, durée
// longue, avancement et issue (§ 5.4, § 5.7, § 5.10, § 14.6). Chaque // 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é, // 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 // 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 // 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 // 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 { 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 // 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é. // 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.nouveauxVoisins', 'Nouveaux voisins à chaque tour'],
['generation.contrainte.nouvelleTable', 'Nouvelle table à chaque tour'], ['generation.contrainte.nouvelleTable', 'Nouvelle table à chaque tour'],
['generation.contrainte.varierAppartenances', 'Varier les appartenances rencontrées'], ['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'], ['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) -------- // --- Les cœurs de calcul, un réglage du poste pour la séance (§ 5.10) --------
// disponibles : les processeurs logiques du poste, hyperthreads compris. // disponibles : les processeurs logiques du poste, hyperthreads compris.
['generation.coeurs.titre', 'Cœurs de calcul'], ['generation.coeurs.titre', 'Cœurs de calcul'],

View file

@ -1,7 +1,8 @@
// © 2026 TechnoLibre (http://www.technolibre.ca) // © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // 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 // 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 // son propre nœud de texte : les libellés qui le suivent ne reçoivent que ce
// qui accorde le nom au compte. // qui accorde le nom au compte.
@ -29,6 +30,11 @@ export const TABLE = new Map([
['import.sorte.avis', 'Avis'], ['import.sorte.avis', 'Avis'],
['import.sorte.information', 'Information'], ['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) ------------------------------------------- // --- Le mode de l'import (§ 10.1) -------------------------------------------
['import.mode.titre', "Mode de l'import"], ['import.mode.titre', "Mode de l'import"],
['import.mode.ajouter', 'Ajouter'], ['import.mode.ajouter', 'Ajouter'],
@ -63,6 +69,14 @@ export const TABLE = new Map([
['import.champ.exclu', 'exclu'], ['import.champ.exclu', 'exclu'],
['import.champ.notes', 'notes'], ['import.champ.notes', 'notes'],
['import.champ.etiquettes', 'étiquettes'], ['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.oui', 'oui'],
['import.exclu.non', 'non'], ['import.exclu.non', 'non'],
['import.colonne.nonReconnue', 'non reconnue : ignorée'], ['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.fusions', 'Orthographes fondues en une appartenance'],
['import.apercu.orthographeLignes', ({ n }) => accorde(n, 'ligne', 'lignes')], ['import.apercu.orthographeLignes', ({ n }) => accorde(n, 'ligne', 'lignes')],
['import.apercu.affichee', 'affichée :'], ['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.doublons', 'Doublons signalés, jamais fusionnés'],
['import.apercu.doublonLignes', 'lignes'], ['import.apercu.doublonLignes', 'lignes'],
['import.apercu.doublonExistants', 'déjà dans la liste :'], ['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.", "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.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.aucuneLigneValide', 'Corrigez les lignes refusées, listées avec leur motif.'],
[ [
'import.remede.guillemetOuvert', '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')}`, ({ 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) --------------------------------------------------- // --- Le rapport (§ 10.1) ---------------------------------------------------
['import.rapport.titre', "Rapport d'import"], ['import.rapport.titre', "Rapport d'import"],
['import.rapport.ajoutes', ({ n }) => accorde(n, 'personne ajoutée', 'personnes ajoutées')], ['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.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.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.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 :'], ['import.rapport.designees', 'désignées :'],
]); ]);

View file

@ -7,7 +7,11 @@
// participants.entete.comptes compose la ligne de comptes de la carte // participants.entete.comptes compose la ligne de comptes de la carte
// (comptesEntete, entete.js), ses parties séparées d'un point médian ; // (comptesEntete, entete.js), ses parties séparées d'un point médian ;
// participants.legende.* nomme la légende des appartenances et chacune de // 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 // 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 // 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.titre', 'Appartenances'],
['participants.legende.element', ({ nom, compte: n }) => `${nom} \u{00B7} ${n}`], ['participants.legende.element', ({ nom, compte: n }) => `${nom} \u{00B7} ${n}`],
['participants.legendeEtiquettes.titre', 'Étiquettes'],
// --- Le formulaire d'ajout (§ 10.2) --------------------------------------- // --- Le formulaire d'ajout (§ 10.2) ---------------------------------------
['participants.formulaire.titre', 'Ajouter une personne'], ['participants.formulaire.titre', 'Ajouter une personne'],
@ -158,6 +163,13 @@ export const TABLE = new Map([
['participants.fiche.horsRetenu', 'hors du placement retenu'], ['participants.fiche.horsRetenu', 'hors du placement retenu'],
['participants.fiche.siegeNonAttribue', 'non attribué'], ['participants.fiche.siegeNonAttribue', 'non attribué'],
['participants.fiche.aucunRetenu', 'Aucun placement retenu ne la nomme.'], ['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) ----------------------------------------- // --- Les statuts calculés (§ 4.2) -----------------------------------------
['participants.statut.ancre', 'ancré'], ['participants.statut.ancre', 'ancré'],

View file

@ -7,32 +7,49 @@
// Chaque en-tête de colonne nomme son unité (§ 5.4) : collisions cumulées, // 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 // 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 // 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 // moyenne n'y paraît pas. La phrase de l'ordre nomme les paliers appliqués,
// par leurs clés moteur.critere.<nom>, dans l'ordre reçu, puis l'identifiant // chaque valeur qu'un palier applique par sa clé moteur.valeur.<valeur>,
// croissant, qui départage deux propositions égales sur tous (§ 5.7). // puis l'identifiant croissant, qui départage deux propositions égales sur
// tous (§ 5.7, A11).
import { compte } from '../../application/libelles/aides.js'; import { compte } from '../../application/libelles/aides.js';
// Le nom d'un critère du classement, celui de la table du moteur. // Le nom d'un critère du classement, celui de la table du moteur.
const critere = (nom, texte) => texte(`moteur.critere.${nom}`); const critere = (nom, texte) => texte(`moteur.critere.${nom}`);
// Un palier, ses critères {nom, poids} : le nom du critère seul ; pour un // Les noms d'un critère appliqué, {valeurs} : chaque valeur qu'il classe,
// palier partagé, « ensemble, » puis chaque critère suivi de son poids. // 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) => const palier = (criteres, texte) =>
criteres.length === 1 criteres.length === 1
? critere(criteres[0].nom, texte) ? valeursDe(criteres[0], texte)
: `${texte('moteur.palier.ensemble')}, ` + : texte('propositions.ordre.ensemble') +
criteres.map(({ nom, poids }) => `${critere(nom, texte)} (${texte('moteur.palier.poids', { poids })})`).join(', '); criteres
.map((applique) => `${valeursDe(applique, texte)}${texte('propositions.ordre.poids', { poids: applique.poids })}`)
.join(texte('propositions.ordre.virgule'));
export const TABLE = new Map([ export const TABLE = new Map([
// --- La section (§ 5.7) ------------------------------------------------------ // --- La section (§ 5.7) ------------------------------------------------------
['propositions.tableau.titre', 'Propositions classées'], ['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."], ['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, // 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 noms des valeurs appliquées, le séparateur des paliers, les mots d'un
// les met bout à bout, chaque nom dans son propre élément ; // palier partagé — « ensemble, », le poids qui suit chaque critère, la
// propositions.ordre.criteres les assemble en une seule chaîne, la même. // 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.debut', "Ordre du classement, chaque critère au plus faible d'abord : "],
['propositions.ordre.separateur', ', puis '], ['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.identifiant', "l'identifiant croissant"],
['propositions.ordre.fin', '.'], ['propositions.ordre.fin', '.'],
[ [
@ -43,9 +60,9 @@ export const TABLE = new Map([
texte('propositions.ordre.fin'), texte('propositions.ordre.fin'),
], ],
// Le critère sauté, puis le palier appliqué à sa place : un critère seul // 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 // s'y nomme par les valeurs qu'il applique ; un palier partagé se lit
// critère et de son poids. applique null, aucun palier ne prend sa place, // « ensemble, » suivi de chaque critère et de son poids. applique null,
// et la seconde phrase se tait. // aucun palier ne prend sa place, et la seconde phrase se tait.
[ [
'propositions.ordre.saute', 'propositions.ordre.saute',
({ saute, applique }, texte) => ({ saute, applique }, texte) =>
@ -88,25 +105,36 @@ export const TABLE = new Map([
['propositions.colonne.effectifManque', 'Personnes à qui il en manque'], ['propositions.colonne.effectifManque', 'Personnes à qui il en manque'],
['propositions.colonne.ecartItineraireMax', "Écart d'itinéraire maximal (personnes)"], ['propositions.colonne.ecartItineraireMax', "Écart d'itinéraire maximal (personnes)"],
['propositions.colonne.ecartAuPlafondAPriori', 'Écart au plafond a priori 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.certificat', 'Certificat'],
['propositions.colonne.imposes', 'Imposé par les réservations'], ['propositions.colonne.imposes', 'Imposé par les réservations'],
['propositions.colonne.commandes', 'Commandes'], ['propositions.colonne.commandes', 'Commandes'],
// --- Les groupes d'en-têtes, au-dessus des colonnes ------------------------- // --- Les groupes d'en-têtes, au-dessus des colonnes -------------------------
// Chaque groupe réunit des colonnes voisines ; les en-têtes de colonne // 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.rencontres', 'Personnes rencontrées'],
['propositions.groupe.collisions', "Collisions d'appartenance"], ['propositions.groupe.collisions', "Collisions d'appartenance"],
['propositions.groupe.repetitions', 'Rencontres répétées et retours'], ['propositions.groupe.repetitions', 'Rencontres répétées et retours'],
['propositions.groupe.diversite', "Diversité d'appartenance"], ['propositions.groupe.diversite', "Diversité d'appartenance"],
['propositions.groupe.manque', 'Manque et plafond a priori'], ['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) -------- // --- 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 // Le texte de l'infobulle d'un en-tête, et celui du nom de la valeur
// désigne la colonne dans la phrase de l'ordre : un critère et sa colonne // appliquée qui désigne la colonne dans la phrase de l'ordre : une valeur
// partagent la clé. Chacune dit ce que la colonne compte, sur quelle // appliquée et sa colonne partagent la clé. Chacune dit ce que la colonne
// population, et dans quel sens elle sert le mieux quand ce sens existe ; // compte, sur quelle population, et dans quel sens elle sert le mieux quand
// les termes sont ceux du glossaire du § 5.5. // 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', 'propositions.explication.rang',
"La place de la proposition dans le classement, 1 pour la première, selon l'ordre écrit au-dessus du tableau. " + "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', 'propositions.explication.collisionsCumulees',
'Chaque fois que deux personnes de la même appartenance partagent une table, compté une fois par paire et par tour : ' + '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.", "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', 'propositions.explication.excedentCollisions',
'Les retrouvailles entre personnes de la même appartenance au-delà de leur première rencontre, soit les collisions ' + '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, six collègues croisés une ' + 'cumulées moins les paires distinctes : deux collègues ensemble aux quatre tours font 3. Critère du classement ' +
'fois chacun font 0. Critère du classement avant les collisions cumulées, car passer la soirée avec le même ' + "placé par l'ordre écrit au-dessus du tableau, par défaut avant les collisions cumulées : passer la soirée avec " +
"collègue est pire que croiser six collègues une fois. Plus il est bas, mieux c'est.", "le même collègue est pire que croiser six collègues une fois. Plus il est bas, mieux c'est.",
], ],
[ [
'propositions.explication.rencontresRepetees', 'propositions.explication.rencontresRepetees',
"Le nombre de paires de personnes réunies à plus d'un tour que le placement a choisies ; celles qu'imposent les " + "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.", "Plus il est bas, mieux c'est.",
], ],
[ [
@ -166,9 +196,11 @@ export const TABLE = new Map([
], ],
[ [
'propositions.explication.retoursChoisis', '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. " + "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 " + "seuls comptent les retours que le placement a choisis, ceux qu'imposent les réservations étant dans la colonne " +
"des imposés. Plus il est bas, mieux c'est.", "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', 'propositions.explication.appartenancesVues',
@ -180,7 +212,8 @@ export const TABLE = new Map([
'propositions.explication.totalRedondance', 'propositions.explication.totalRedondance',
"r(p) : le nombre de personnes que p rencontre et qui ont une appartenance, moins A(p), le nombre d'appartenances " + "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 " + "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', 'propositions.explication.diversite',
@ -209,10 +242,56 @@ export const TABLE = new Map([
], ],
[ [
'propositions.explication.ecartAuPlafondAPriori', '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. " + "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 " + "La colonne donne le plus grand, critère du classement à sa place dans l'ordre écrit au-dessus du tableau ; " +
"calculé, la cellule écrivant alors « inconnu ». Plus il est bas, mieux c'est.", "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', 'propositions.explication.certificat',

View file

@ -11,6 +11,7 @@
// composé par texte depuis son code. Les noms de personnes et les numéros de // 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 // table viennent des détails : une fenêtre à confirmer nomme chaque élément
// détruit dans son propre libellé. // détruit dans son propre libellé.
import { JOINTURE_ETIQUETTES } from '../../application/csv.js';
import { cite, compte } from '../../application/libelles/aides.js'; import { cite, compte } from '../../application/libelles/aides.js';
// Un accord au nombre : le singulier jusqu'à un, comme compte. // 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é. // 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}`); 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([ export const TABLE = new Map([
// --- Le défaut (§ 6.1) ------------------------------------------------------ // --- Le défaut (§ 6.1) ------------------------------------------------------
['tables.defaut.etiquette', 'Places par défaut'], ['tables.defaut.etiquette', 'Places par défaut'],
@ -118,6 +127,10 @@ export const TABLE = new Map([
`${reservation.siege === null ? '' : `, siège ${reservation.siege}`}`, `${reservation.siege === null ? '' : `, siège ${reservation.siege}`}`,
], ],
['tables.suppression.titre', ({ siege, libelle }) => `Titre ${cite(libelle)}, siège ${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) ---------------------------------------- // --- Les tours (§ 2.1 étape 5, § 4.3) ----------------------------------------
['tables.tours.etiquette', 'Nombre de tours'], ['tables.tours.etiquette', 'Nombre de tours'],
@ -129,6 +142,30 @@ export const TABLE = new Map([
'tables.tours.reservation', 'tables.tours.reservation',
({ nom, numero, tour }) => `${nom} : réservation à la table ${numero} au tour ${tour}, détruite`, ({ 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 // Un placement qu'un changement laisse en dérive (§ 9) : il reste, et ne
// correspond plus à la configuration. // correspond plus à la configuration.

View file

@ -2,18 +2,43 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) --> License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script> <script>
// La fiche d'une personne à l'écran (§ 4, § 4.2, § 7.4) : nom, prénom, // La fiche d'une personne à l'écran (§ 4, § 4.2, § 4.5, § 7.4, § 10.2) :
// appartenance, courriel, titre pressenti, notes, statut calculé, ses // nom, prénom, appartenance, courriel, titre pressenti, notes, statut
// réservations, et sa table et son siège à chaque tour du retenu ; un // calculé, ses étiquettes, ses réservations, et sa table et son siège à
// tour que le retenu ne porte pas se dit hors du retenu. La fiche // chaque tour du retenu ; un tour que le retenu ne porte pas se dit hors
// replie, ne tronque jamais : un nom de deux cents caractères s'y lit // du retenu. La fiche replie, ne tronque jamais : un nom de deux cents
// entier, sur autant de lignes qu'il en faut. Chaque donnée occupe son // caractères s'y lit entier, sur autant de lignes qu'il en faut. Chaque
// propre nœud de texte, et rien n'y est interprété comme du balisage. // 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 // 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 // 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 // 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, // 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 // 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 // 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 // jusque dans la fenêtre : dans une fenêtre étroite, elle se pose sous la
// grille. // grille.
import { getContext } from 'svelte'; import { getContext, onDestroy, tick, untrack } from 'svelte';
import { nomAffiche } from '../../application/personnes.js'; import { nomAffiche } from '../../application/personnes.js';
import { etatPlaces, statuts } from '../../application/places.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(); let { participant, charge } = $props();
const t = getContext('t'); const t = getContext('t');
const ui = getContext('ui'); 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. // Les valeurs de la fiche, sous l'étiquette de leur colonne.
const CHAMPS = ['nom', 'prenom', 'appartenance', 'courriel', 'titrePressenti', 'notes']; const CHAMPS = ['nom', 'prenom', 'appartenance', 'courriel', 'titrePressenti', 'notes'];
@ -44,10 +78,92 @@
const reservations = $derived(reservationsDe(charge, participant.id)); const reservations = $derived(reservationsDe(charge, participant.id));
const etapes = $derived(itineraire(participant.id, charge.evenement.tours, (tour) => etatPlaces(charge, { tour }))); 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 element;
let champ;
$effect(() => { $effect(() => {
if (participant.id !== null) element.scrollIntoView({ block: 'nearest' }); 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> </script>
<aside bind:this={element} class="fiche" data-fiche={participant.id} aria-label={t('participants.fiche.titre')}> <aside bind:this={element} class="fiche" data-fiche={participant.id} aria-label={t('participants.fiche.titre')}>
@ -67,6 +183,63 @@
<dd>{statut}</dd> <dd>{statut}</dd>
</dl> </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> <table>
<caption>{t('participants.fiche.reservations')}</caption> <caption>{t('participants.fiche.reservations')}</caption>
{#if reservations.length > 0} {#if reservations.length > 0}
@ -181,7 +354,8 @@
} }
dt, dt,
caption { caption,
h4 {
color: var(--couleur-page-texte-secondaire); color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-intertitre); font-size: var(--taille-intertitre);
font-weight: bold; font-weight: bold;
@ -224,6 +398,114 @@
color: var(--couleur-page-texte-secondaire); 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 { button {
padding: var(--espace-1) var(--espace-3); padding: var(--espace-1) var(--espace-3);
border: 1px solid var(--couleur-page-filet); 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 // § 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, // 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 // 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 // et l'itinéraire, les étiquettes de la fiche et leur légende, l'export, le
// colonne ou plusieurs, ses groupes imbriqués, repliés et dépliés, et les // collage, et la liste groupée par une colonne ou plusieurs, ses groupes
// conflits du placement montré sous ses groupes. Chaque épreuve monte App par // 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 // 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 // é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 // 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(); await rendu();
expect(fiche(m).dataset.fiche).toBe(String(id)); expect(fiche(m).dataset.fiche).toBe(String(id));
expect(valeurDeFiche(m, 'participants.fiche.statut').textContent).toBe(t(cleDeStatut(statut))); 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)', () => { 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 () => { test('« Exporter la liste » remet les octets d’exporterParticipants ; réimportés dans un événement neuf, ils rendent les mêmes participants, exclu compris', async () => {
const m = await evenementOuvert(); const m = await evenementOuvert();

View file

@ -5,8 +5,8 @@
// La section des participants (§ 2.1 étape 3, § 4, § 10.2) : une carte // 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, // 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, // 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 // puis la barre du filtre, du groupement, du compte et des légendes des
// appartenances, puis la grille ; à côté, la fiche ouverte, ou dessous // 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 // 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 // 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 // section : regarder, trier et filtrer n'écrivent rien, en écriture comme
@ -39,7 +39,12 @@
// Les teintes d'appartenance (teintesAppartenances) se calculent une fois // Les teintes d'appartenance (teintesAppartenances) se calculent une fois
// sur la liste entière, exclues comprises, et la légende comme la grille // 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 // 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 à // 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 // confirmer, qui les nomme ; sans réservation, ou quand la séance refuse
@ -60,7 +65,7 @@
import { statuts } from '../../application/places.js'; import { statuts } from '../../application/places.js';
import { conflitsDuPlacement } from '../../application/conflits_placement.js'; import { conflitsDuPlacement } from '../../application/conflits_placement.js';
import { conflitsDesGroupes, lignesDeConflit, reglesParPersonne } from './conflits.js'; import { conflitsDesGroupes, lignesDeConflit, reglesParPersonne } from './conflits.js';
import { comptesEntete, legendeAppartenances } from './entete.js'; import { comptesEntete, legendeAppartenances, legendeEtiquettes } from './entete.js';
import { import {
choisirGroupement, choisirGroupement,
cleDeGroupement, cleDeGroupement,
@ -93,6 +98,7 @@
const teintes = $derived(teintesAppartenances(participants)); const teintes = $derived(teintesAppartenances(participants));
const comptes = $derived(comptesEntete(participants)); const comptes = $derived(comptesEntete(participants));
const legende = $derived(legendeAppartenances(participants, teintes)); const legende = $derived(legendeAppartenances(participants, teintes));
const legendeDesEtiquettes = $derived(legendeEtiquettes(participants));
const lignes = $derived(lignesGrille(participants, { tri, filtre }, cleNormalisee)); const lignes = $derived(lignesGrille(participants, { tri, filtre }, cleNormalisee));
const toutes = $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); const parStatut = $derived(groupement.includes('statut') && charge !== null ? statuts(charge).parId : null);
@ -179,12 +185,25 @@
</button> </button>
{/if} {/if}
<output>{t('participants.grille.compte', { total: participants.length, affiches: lignes.length })}</output> <output>{t('participants.grille.compte', { total: participants.length, affiches: lignes.length })}</output>
{#if legende.length > 0} {#if legende.length > 0 || legendeDesEtiquettes.length > 0}
<ul class="legende" aria-label={t('participants.legende.titre')}> <div class="legendes">
{#each legende as { nom, rang, compte } (nom)} {#if legende.length > 0}
<li><Pastille sorte="appartenance" valeur={rang} libelle={t('participants.legende.element', { nom, compte })} /></li> <ul class="legende" aria-label={t('participants.legende.titre')}>
{/each} {#each legende as { nom, rang, compte } (nom)}
</ul> <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} {/if}
</div> </div>
@ -280,13 +299,25 @@
font-size: var(--taille-texte-petit); 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 { .legende {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-self: center; justify-content: flex-end;
gap: var(--espace-1) var(--espace-2); gap: var(--espace-1) var(--espace-2);
min-width: 0; min-width: 0;
margin: 0 0 0 auto; margin: 0;
padding: 0; padding: 0;
list-style: none; list-style: none;
} }

View file

@ -2,10 +2,14 @@
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // 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 // 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 // carte, la légende des appartenances et celle des étiquettes. Toutes se
// entière, exclues comprises — la population de l'attribution des teintes // lisent sur la liste entière, exclues comprises — la population de
// (teintesAppartenances, appartenances.js) — : un filtre ne change ni un // l'attribution des teintes (teintesAppartenances, appartenances.js) — : un
// compte ni une teinte. Aucune fonction ne modifie ce qu'elle reçoit. // 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. * La ligne de comptes de la carte des participants.
@ -49,3 +53,27 @@ export function legendeAppartenances(participants, teintes) {
} }
return legende; 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) // © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // 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 // 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 // la légende des appartenances et celle des étiquettes, chacune lue sur la
// comprises, et le texte que les libellés en composent. Les noms d'épreuve // liste entière, exclues comprises, et le texte que les libellés en
// sont inventés. // composent. Les noms d'épreuve sont inventés.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js'; import { describe, test } from '../../../test/lanceur.js';
import { TABLES_APPLICATION } from '../../application/libelles.js'; import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js'; import { creerTraduction } from '../../application/traduction.js';
import { RANGS_APPARTENANCE, teintesAppartenances } from '../appartenances.js'; import { RANGS_APPARTENANCE, teintesAppartenances } from '../appartenances.js';
import { TABLES_INTERFACE } from '../libelles.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]); 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'); 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']], ['chevron', ['M9 6l6 6-6 6']],
['points', [cercle(5, 12, 1.5), cercle(12, 12, 1.5), cercle(19, 12, 1.5)]], ['points', [cercle(5, 12, 1.5), cercle(12, 12, 1.5), cercle(19, 12, 1.5)]],
['demarrer', ['M8 5l11 7-11 7z']], ['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. */ /** 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, [ assert.deepEqual(NOMS_ICONES, [
'crayon', 'listePoints', 'coche', 'cadenas', 'liste', 'importer', 'exporter', 'ajouter', 'chercher', 'crayon', 'listePoints', 'coche', 'cadenas', 'liste', 'importer', 'exporter', 'ajouter', 'chercher',
'calendrier', 'information', 'triangle', 'interdit', 'dossier', 'oeil', 'stylo', 'defaire', 'imprimer', '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.ok(Object.isFrozen(ICONES) && Object.isFrozen(NOMS_ICONES));
assert.deepEqual(Object.keys(ICONES).sort(), [...NOMS_ICONES].sort()); assert.deepEqual(Object.keys(ICONES).sort(), [...NOMS_ICONES].sort());

View file

@ -18,6 +18,7 @@
import { afterEach, beforeEach, describe, expect, test } from 'vitest'; import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { flushSync } from 'svelte'; import { flushSync } from 'svelte';
import { appliquerCommande } from '../../application/commandes.js'; import { appliquerCommande } from '../../application/commandes.js';
import { configurationDe } from '../../application/configuration.js';
import { creerFormat } from '../../application/format.js'; import { creerFormat } from '../../application/format.js';
import { TABLES_APPLICATION } from '../../application/libelles.js'; import { TABLES_APPLICATION } from '../../application/libelles.js';
import { nomAffiche } from '../../application/personnes.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 { monterApp } from '../../../test/navigateur/montage.js';
import { composantes, contraste, couleurTexte, fondPeintSous, sousTheme } from '../../../test/navigateur/rendu.js'; import { composantes, contraste, couleurTexte, fondPeintSous, sousTheme } from '../../../test/navigateur/rendu.js';
import { TABLES_INTERFACE } from '../libelles.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 { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const format = creerFormat(t); const format = creerFormat(t);
@ -1095,7 +1096,10 @@ describe('l’habillage du tableau des propositions', () => {
const thead = section(m).querySelector('thead'); const thead = section(m).querySelector('thead');
expect(thead.rows.length).toBe(2); expect(thead.rows.length).toBe(2);
const groupes = [...thead.rows[0].querySelectorAll('th')]; 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(groupes.map(texteDe)).toEqual(attendus.map(({ cle }) => t(cle)));
expect(thead.rows[0].querySelectorAll('[data-colonne]').length).toBe(0); 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); 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); expect(g.bottom).toBeLessThanOrEqual(premiere.top + 1);
} }
const colgroups = [...section(m).querySelectorAll('table > colgroup')]; 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 () => { 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) --------------- // --- 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, // 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. // « Retenir » et « Effacer », le refus posé à côté du bouton.
// //
// La table d'évaluation se lit dans la charge de l'état observable, par // 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 // evaluation (modele.js) : une seule mesure, celle d'evaluer
// à chaque charge écrite ; les indicateurs du retenu suivent donc // (evaluation.js), refaite à chaque charge écrite ; les indicateurs du
// l'ajustement (§ 2.1 étape 11). // retenu suivent donc l'ajustement (§ 2.1 étape 11).
// //
// « Voir sur le plan » demande la proposition au plan par l'état de // « 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 // 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 // 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. // se défait — : il s'exécute sans fenêtre.
// //
// La phrase de l'ordre se met bout à bout, morceau par morceau (modele.js) ; // 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 // les paliers appliqués, chaque nom celui de la valeur que son palier
// focalisable qui désigne sa colonne. Le survoler ou le focaliser surligne // applique — deux noms pour un critère qui en classe deux. Chaque nom, et
// cette colonne dans le tableau. Le survol et le focus se tiennent à part, // l'identifiant croissant, est un élément focalisable qui porte le critère
// et surlignee (modele.js) choisit : le nom survolé, sinon celui qui a le // qui le classe (data-critere) et désigne la colonne de sa valeur. Le
// focus. Quitter un nom — pointeur sorti, focus perdu — n'efface que l'état // survoler ou le focaliser surligne cette colonne dans le tableau. Le
// qu'il tenait, et laisse l'autre. // 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 // Chaque nom de la phrase, celui d'une valeur appliquée, et chaque en-tête
// l'infobulle de la section, une seule (Infobulle, primitive) : le survol // de colonne s'expliquent dans l'infobulle de la section, une seule
// ou le focus l'ouvre sur le terme, le quitter la referme quand elle est // (Infobulle, primitive) : le survol ou le focus l'ouvre sur le terme, le
// encore la sienne, Échap aussi. Un critère et sa colonne partagent leur // quitter la referme quand elle est encore la sienne, Échap aussi. Une
// explication (explicationDeColonne, modele.js). Le terme expliqué porte // valeur appliquée et sa colonne partagent leur explication
// aria-describedby vers elle, et lui seul. // (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 // 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 // à page : pagine découpe le classement unique, sans le retrier, et la
@ -163,10 +166,10 @@
const surlignee = $derived(colonneSurlignee(survolee, focalisee)); const surlignee = $derived(colonneSurlignee(survolee, focalisee));
// Le terme expliqué : {ancre, terme, cle, details} — son élément, sa // Le terme expliqué : {ancre, terme, cle, details} — son élément, sa
// désignation, 'critere:<critère>' pour un nom de la phrase ou // désignation, 'valeur:<valeur>' pour un nom de la phrase, où chaque
// 'colonne:<nom>' pour un en-tête, et la clé de son explication — ; null // valeur paraît une fois, ou 'colonne:<nom>' pour un en-tête, et la clé
// sans infobulle. Ouvrir sur un terme remplace l'explication d'un autre ; // de son explication — ; null sans infobulle. Ouvrir sur un terme remplace
// quitter un terme ne ferme que la sienne. // l'explication d'un autre ; quitter un terme ne ferme que la sienne.
const uid = $props.id(); const uid = $props.id();
const idExplication = `${uid}-explication`; const idExplication = `${uid}-explication`;
let expliquee = $state(null); let expliquee = $state(null);
@ -181,15 +184,15 @@
const decrit = (terme) => (expliquee?.terme === terme ? idExplication : undefined); const decrit = (terme) => (expliquee?.terme === terme ? idExplication : undefined);
// L'entrée sur un nom, par le pointeur ou par le focus, et sa sortie. // 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; if (parFocus) focalisee = colonne;
else survolee = 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; if (parFocus) focalisee = null;
else survolee = null; else survolee = null;
taire(`critere:${critere}`); taire(`valeur:${valeur}`);
} }
const effacer = () => seance.executer('effacerPropositions', {}); const effacer = () => seance.executer('effacerPropositions', {});
@ -254,7 +257,7 @@
class:actif={surlignee === morceau.colonne} class:actif={surlignee === morceau.colonne}
data-critere={morceau.critere} data-critere={morceau.critere}
tabindex="0" tabindex="0"
aria-describedby={decrit(`critere:${morceau.critere}`)} aria-describedby={decrit(`valeur:${morceau.valeur}`)}
onmouseenter={(evenement) => entrer(evenement, morceau, false)} onmouseenter={(evenement) => entrer(evenement, morceau, false)}
onmouseleave={() => sortir(morceau, false)} onmouseleave={() => sortir(morceau, false)}
onfocus={(evenement) => entrer(evenement, morceau, true)} onfocus={(evenement) => entrer(evenement, morceau, true)}

View file

@ -11,8 +11,10 @@
// Les violations d'une ligne comparable — retenu compris — se lisent sous // Les violations d'une ligne comparable — retenu compris — se lisent sous
// son identifiant. // son identifiant.
// //
// vue : ce que rend tableau(), libellés et étendues compris, ses lignes // vue : ce que rend tableau(), libellés et étendues compris — ses colonnes
// et ses lignes hors classement réduites à celles de la page montrée // 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) : // (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 // le nom affiché d'une personne, null pour id null ou pour une personne que
// la charge n'a plus. voir(id) et // 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 // 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 // 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 // decrit('colonne:<nom>') rend l'identifiant de l'infobulle quand elle
// explique cet en-tête, pour son aria-describedby, undefined sinon. // 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- // chacun en scope colgroup au-dessus de ses colonnes, une cellule vide au-
// dessus d'une colonne hors groupe ; puis celle des colonnes, seule à // dessus d'une colonne hors groupe ; puis celle des colonnes, seule à
// porter data-colonne et les explications. Les colonnes de nombres // porter data-colonne et les explications. Les colonnes de nombres
@ -50,7 +53,7 @@
import Commande from '../primitives/Commande.svelte'; import Commande from '../primitives/Commande.svelte';
import Icone from '../primitives/Icone.svelte'; import Icone from '../primitives/Icone.svelte';
import Pastille from '../primitives/Pastille.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(); let { vue, surlignee = null, nommer, voir, retenir, expliquer, taire, decrit } = $props();
@ -123,12 +126,12 @@
<div class="defilement"> <div class="defilement">
<table aria-label={t('propositions.tableau.titre')}> <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> <colgroup span={segment.etendue}></colgroup>
{/each} {/each}
<thead> <thead>
<tr class="groupes"> <tr class="groupes">
{#each GROUPES_COLONNES as segment, rang (rang)} {#each vue.groupes as segment, rang (rang)}
{#if segment.cle === null} {#if segment.cle === null}
<td></td> <td></td>
{:else} {:else}

View file

@ -5,14 +5,14 @@
// § 12.6, § 12.9), pur : SectionPropositions.svelte et // § 12.6, § 12.9), pur : SectionPropositions.svelte et
// TableauPropositions.svelte le câblent. // TableauPropositions.svelte le câblent.
// //
// Le tableau lit la table d'évaluation d'evaluer (evaluation.js), la seule mesure : chaque // Le tableau lit la table d'évaluation d'evaluer (evaluation.js), la seule
// cellule porte la valeur que la table rend, telle quelle, et sa clé de mise // mesure : chaque cellule porte la valeur que la table rend, telle quelle, et
// en forme ; aucune arithmétique ne s'y fait, aucun plafond ne s'y recompose. // sa clé de mise en forme ; aucune arithmétique ne s'y fait, aucun plafond
// Des rencontres, il ne porte que le minimum, sur les trois populations du // ne s'y recompose. Des rencontres, il ne porte que le minimum, sur les trois
// § 5.4 — tous, mobiles, ancrés —, en tête des colonnes de mesure ; la moyenne // populations du § 5.4 — tous, mobiles, ancrés —, en tête des colonnes de
// n'y entre pas. Le plafond réalisé, individuel, se lit par le manque, dont // mesure ; la moyenne n'y entre pas. Le plafond réalisé, individuel, se lit
// les cellules sont celles de troisChiffres. Les imposés se disent à part, sur // par le manque, dont les cellules sont celles de troisChiffres. Les imposés
// chaque ligne, jamais ajoutés aux choisis. // se disent à part, sur chaque ligne, jamais ajoutés aux choisis.
// //
// Chaque minimum affiché désigne la personne la plus mal servie (§ 12.1) : // Chaque minimum affiché désigne la personne la plus mal servie (§ 12.1) :
// le moins de rencontres, d'appartenances vues, de diversité. La redondance // 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 // 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. // 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 // Une cellule vaut { colonne, valeur, cle, decimales } : cle null, la valeur
// est un nombre que format.nombre écrit avec decimales chiffres après la // est un nombre que format.nombre écrit avec decimales chiffres après la
// virgule ; sinon le texte est t(cle) — « — » pour une population vide, // 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], ['effectifManque', (l) => l.troisChiffres.effectifManque.tous],
['ecartItineraireMax', (l) => l.troisChiffres.ecartItineraireMax?.tous ?? null], ['ecartItineraireMax', (l) => l.troisChiffres.ecartItineraireMax?.tous ?? null],
['ecartAuPlafondAPriori', (l) => l.ecartAuPlafondAPrioriMax], ['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], ['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 // Les colonnes dont la valeur n'existe pas quand le plafond a priori est
// inconnu : elles s'écrivent « inconnu », jamais « — » (§ 12.6). // inconnu : elles s'écrivent « inconnu », jamais « — » (§ 12.6).
const SELON_A_PRIORI = new Set(['ecartItineraireMax', 'ecartAuPlafondAPriori']); 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 /** Les colonnes du tableau, dans l'ordre d'affichage : le rang et
* l'identifiant, puis les mesures — les trois minimums des rencontres en * 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, * 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 * le texte de l'infobulle de son en-tête et de la valeur appliquée qui la
* nombre, vrai quand ses cellules portent un nombre. */ * désigne, et nombre, vrai quand ses cellules portent un nombre. */
export const COLONNES = Object.freeze( export const COLONNES = Object.freeze(
['rang', 'proposition', ...LECTURES.keys(), 'imposes', 'commandes'].map((nom) => ['rang', 'proposition', ...LECTURES.keys(), 'imposes', 'commandes'].map((nom) =>
Object.freeze({ nom, cle: `propositions.colonne.${nom}`, explication: `propositions.explication.${nom}`, nombre: !TEXTUELLES.has(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']], ['repetitions', ['rencontresRepetees', 'maxRencontresPaire', 'retoursChoisis']],
['diversite', ['appartenancesVues', 'totalRedondance', 'diversite']], ['diversite', ['appartenancesVues', 'totalRedondance', 'diversite']],
['manque', ['manqueMax', 'effectifManque', 'ecartItineraireMax', 'ecartAuPlafondAPriori']], ['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 // Les segments de la rangée des groupes sur les colonnes noms, une suite de
// groupe couvre ses colonnes d'un seul segment, une colonne hors groupe // COLONNES : un groupe couvre celles de ses colonnes que noms porte d'un seul
// forme le sien, sans clé. Un groupe dont les colonnes ne se suivent pas // segment, une colonne hors groupe forme le sien, sans clé ; un groupe dont
// dans COLONNES lève au chargement du module. // noms ne porte aucune colonne n'a pas de segment. Un groupe dont les
function segmenter() { // colonnes ne se suivent pas dans noms lève : au chargement du module pour
const noms = COLONNES.map(({ nom }) => nom); // COLONNES entier.
function segmenter(noms) {
const segments = []; const segments = [];
for (let rang = 0; rang < noms.length; ) { 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 (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]] }); segments.push({ cle: null, colonnes: [noms[rang]] });
rang += 1; rang += 1;
continue; continue;
} }
const [nom, colonnes] = groupe; 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`); if (noms[rang + decalage] !== colonne) throw new Error(`groupe ${nom} : colonnes non voisines`);
}); });
segments.push({ cle: `propositions.groupe.${nom}`, colonnes }); segments.push({ cle: `propositions.groupe.${nom}`, colonnes: portees });
rang += colonnes.length; 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 : /** La rangée des groupes d'en-têtes de COLONNES entier, au-dessus de celle
* { cle, etendue, colonnes } par segment, dans l'ordre de COLONNES — cle, * des colonnes : { cle, etendue, colonnes } par segment, dans l'ordre de
* la clé du libellé du groupe, null pour une colonne hors groupe ; * COLONNES — cle, la clé du libellé du groupe, null pour une colonne hors
* etendue, le nombre de colonnes couvertes ; colonnes, leurs noms. Les * groupe ; etendue, le nombre de colonnes couvertes ; colonnes, leurs noms.
* segments couvrent chaque colonne une fois. */ * Les segments couvrent chaque colonne une fois. tableau() rend ceux des
export const GROUPES_COLONNES = Object.freeze(segmenter()); * 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 * 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 * en-tête, et celui du nom qui la désigne dans la phrase de l'ordre
* l'ordre (colonneDuCritere) — un critère et sa colonne partagent la même. * (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. * Lève TypeError pour un nom qui n'est pas une colonne de COLONNES.
* *
* @param {string} nom le nom d'une colonne * @param {string} nom le nom d'une colonne
@ -154,12 +203,12 @@ function certificat(ligne) {
return { colonne: 'certificat', valeur, cle, decimales: 0 }; return { colonne: 'certificat', valeur, cle, decimales: 0 };
} }
// Les cellules de mesure d'une ligne comparable, dans l'ordre de LECTURES. // Les cellules de mesure d'une ligne comparable, une par colonne de mesure
// L'a priori est inconnu quand troisChiffres ne porte pas d'écart // portée, dans l'ordre de LECTURES. L'a priori est inconnu quand
// d'itinéraire du tout. // troisChiffres ne porte pas d'écart d'itinéraire du tout.
function cellules(ligne) { function cellules(ligne, portees) {
const inconnu = ligne.troisChiffres.ecartItineraireMax === null; 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 (colonne === 'certificat') return certificat(ligne);
if (inconnu && SELON_A_PRIORI.has(colonne)) return { colonne, valeur: null, cle: INCONNU, decimales: 0 }; if (inconnu && SELON_A_PRIORI.has(colonne)) return { colonne, valeur: null, cle: INCONNU, decimales: 0 };
return nombre(colonne, lire(ligne)); return nombre(colonne, lire(ligne));
@ -181,14 +230,11 @@ const codeDe = (raison) => ({ cle: raison.code, details: raison });
// null quand elle n'en nomme aucune. // null quand elle n'en nomme aucune.
const raisonDe = (raison) => ({ ...codeDe(raison), participant: raison.participant ?? null }); 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. // 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 // portees : les noms des colonnes que porte le tableau ; rang : la place
// et pour le retenu. // dans le classement, à partir de 1 ; null hors classement et pour le
function ligneDe(ligne, table, idRetenu, rang = null) { // retenu.
function ligneDe(ligne, table, idRetenu, portees, rang = null) {
const retenue = ligne.id === idRetenu; const retenue = ligne.id === idRetenu;
if (ligne.statut !== 'comparable') { if (ligne.statut !== 'comparable') {
return { return {
@ -205,83 +251,106 @@ function ligneDe(ligne, table, idRetenu, rang = null) {
rang, rang,
retenue, retenue,
comparable: true, comparable: true,
cellules: cellules(ligne), cellules: cellules(ligne, portees),
imposes: imposes(ligne, table.imposes), imposes: imposes(ligne, table.imposes),
violations: ligne.violations.map(codeDe), violations: ligne.violations.map(codeDe),
}; };
} }
// La colonne que désigne chaque critère du classement (§ 5.7) : celle qui // La colonne que désigne chaque valeur qui classe (§ 5.7, A11) : celle qui
// porte la grandeur qu'il compare ; null pour les tours hors objectif et les // la porte ; l'identifiant croissant, le dernier départage, désigne la
// animateurs non rencontrés, que le tableau ne porte pas en colonne. La Map // colonne des propositions. La Map ne sert qu'à retrouver la colonne d'une
// ne sert qu'à retrouver la colonne d'un critère ; la phrase suit l'ordre que // valeur ; la phrase suit l'ordre que le classement rend.
// le classement rend. const COLONNE_DE_VALEUR = new Map([
const COLONNE_DU_CRITERE = new Map([
['ecartAuPlafondAPrioriMax', 'ecartAuPlafondAPriori'], ['ecartAuPlafondAPrioriMax', 'ecartAuPlafondAPriori'],
['excedentCollisions', 'excedentCollisions'], ['excedentCollisions', 'excedentCollisions'],
['collisionsCumulees', 'collisionsCumulees'], ['collisionsCumulees', 'collisionsCumulees'],
['toursHorsObjectif', null], ['jamaisServies', 'jamaisServies'],
['rencontresRepetees', 'rencontresRepetees'], ['horsObjectifMax', 'horsObjectifMax'],
['animateursNonRencontres', null], ['toursHorsObjectif', 'toursHorsObjectif'],
['rencontresRepeteesChoisies', 'rencontresRepetees'],
['retoursChoisis', 'retoursChoisis'],
['animateursNonRencontres', 'animateursNonRencontres'],
['redondance', 'totalRedondance'], ['redondance', 'totalRedondance'],
['identifiant', 'proposition'], ['identifiant', 'proposition'],
]); ]);
/** /**
* La colonne du tableau que désigne un critère du classement, ou * La colonne du tableau que désigne une valeur qui classe (valeursQuiClassent,
* 'identifiant', le dernier départage, qui désigne la colonne des * criteres.js), ou 'identifiant', le dernier départage, qui désigne la
* propositions ; null pour un critère du registre que le tableau ne porte * colonne des propositions. Lève TypeError pour un nom que la table ne
* pas en colonne. Lève TypeError pour un nom que la table ne connaît pas : * connaît pas : une valeur ajoutée au classement doit dire laquelle elle
* un critère ajouté au classement doit dire laquelle il compare, ou * compare.
* qu'il n'en a pas.
* *
* @param {string} critere un nom de CRITERES (criteres.js), ou 'identifiant' * @param {string} valeur une valeur qui classe, ou 'identifiant'
* @returns {string|null} le nom d'une colonne de COLONNES, ou null * @returns {string} le nom d'une colonne de COLONNES
*/ */
export function colonneDuCritere(critere) { export function colonneDeValeur(valeur) {
const colonne = COLONNE_DU_CRITERE.get(critere); const colonne = COLONNE_DE_VALEUR.get(valeur);
if (colonne === undefined) throw new TypeError(`critère sans colonne : ${critere}`); if (colonne === undefined) throw new TypeError(`valeur sans colonne : ${valeur}`);
return colonne; 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 // 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. // colonne — celui d'une valeur appliquée, sous la clé
const liaison = (cle, details = {}) => ({ cle, details, critere: null, colonne: null }); // moteur.valeur.<valeur>, avec le critère qui la classe.
const designant = (critere, cle) => ({ cle, details: {}, critere, colonne: colonneDuCritere(critere) }); 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 // Des listes de morceaux mises bout à bout, le morceau separateur entre deux.
// critère de la liste aplatie par son nom moteur.critere.<nom>, puis const joindre = (listes, separateur) => listes.flatMap((morceaux, rang) => (rang === 0 ? morceaux : [separateur(), ...morceaux]));
// l'identifiant croissant, séparés, et la fin. Bout à bout, les morceaux
// traduits composent le texte de propositions.ordre.criteres sur la liste // Les noms d'un critère appliqué, {nom, valeurs, poids} : chaque valeur
// aplatie : sous l'ordre par défaut et les quatre cases cochées, celui de // qu'il classe, dans l'ordre, séparées par « puis » comme deux paliers.
// l'ordre fixe de référence. const nomsDuCritere = ({ nom, valeurs }) =>
function phraseDesCriteres(criteresAppliques) { joindre(
const noms = [ valeurs.map((valeur) => [designant(nom, valeur)]),
...aplatir(criteresAppliques).map((critere) => designant(critere, `moteur.critere.${critere}`)), () => liaison('propositions.ordre.separateur'),
designant('identifiant', 'propositions.ordre.identifiant'), );
// 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 [ return [
liaison('propositions.ordre.debut'), 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'), liaison('propositions.ordre.fin'),
]; ];
} }
// Le palier qui prend la place du palier sauté : ses critères {nom, poids}, // 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 // valeurs}, ceux du palier appliqué au rang rangSaute ; null quand aucun
// suit le palier sauté. // palier ne suit le palier sauté.
function palierAppliqueASaPlace({ criteresAppliques, rangSaute }) { function palierAppliqueASaPlace({ criteresAppliques, rangSaute }) {
const palier = 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 // 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 // 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) { function ordre(classement) {
const { criteresAppliques, critereSaute } = classement; const { criteresAppliques, critereSaute } = classement;
const phrases = []; const phrases = [];
@ -300,13 +369,18 @@ function ordre(classement) {
* placement de sa proposition d'origine (retenuAjuste) ; il choisit le * placement de sa proposition d'origine (retenuAjuste) ; il choisit le
* libellé de la ligne du retenu * libellé de la ligne du retenu
* @returns {{ordre: Array<Array<{cle: string, details: Object, critere: string|null, * @returns {{ordre: Array<Array<{cle: string, details: Object, critere: string|null,
* colonne: string|null}>>, colonnes: typeof COLONNES, * valeur: string|null, colonne: string|null}>>, colonnes: typeof COLONNES,
* etendueRaisons: number, lignes: Object[], horsClassement: Object[], retenu: Object|null}} * 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 * 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 * traduit et que l'affichage met bout à bout ; un morceau qui nomme une
* critère, ou l'identifiant croissant, porte critere et la colonne qu'il * valeur appliquée, ou l'identifiant croissant, porte le critère qui la
* désigne, null pour un texte de liaison ; lignes : les comparables dans * classe, la valeur et la colonne qu'elle désigne — 'identifiant' pour
* l'ordre du classement, chacune { id, rang, retenue, comparable: true, * 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 } ; * cellules, imposes, violations } ;
* horsClassement : les autres par identifiant, { id, rang: null, retenue, * horsClassement : les autres par identifiant, { id, rang: null, retenue,
* comparable: false, statut, raisons } — statut la clé de « non comparable », * comparable: false, statut, raisons } — statut la clé de « non comparable »,
@ -319,24 +393,29 @@ function ordre(classement) {
*/ */
export function tableau(tableEvaluation, { ajuste = false } = {}) { export function tableau(tableEvaluation, { ajuste = false } = {}) {
const idRetenu = tableEvaluation.retenu?.id ?? null; 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 // Les comparables ouvrent la liste, dans l'ordre du classement : leur
// place y est leur rang. // 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 { return {
ordre: ordre(tableEvaluation.classement), ordre: ordre(tableEvaluation.classement),
colonnes: COLONNES, colonnes,
etendueRaisons: ETENDUE_RAISONS, 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), lignes: toutes.filter(({ comparable }) => comparable),
horsClassement: 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, // La ligne du retenu, et son libellé : « tel que » la proposition d'origine,
// ou « ajusté » depuis elle. // ou « ajusté » depuis elle.
function ligneDuRetenu(tableEvaluation, idRetenu, ajuste) { function ligneDuRetenu(tableEvaluation, idRetenu, portees, ajuste) {
const cle = ajuste ? 'propositions.retenu.ajuste' : 'propositions.retenu.origine'; 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. // remplacerait.
const CHAMPS_DU_PLACEMENT = ['siegesAttribues', 'tables', 'capacites', 'tours', 'participants', 'placement']; 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 : /** Vrai quand un retenu existe et ne porte plus le placement de sa
* tables, capacités, tours, participants ou placement diffèrent, en profondeur, de ceux * proposition d'origine : tables, capacités, tours, participants ou
* de la proposition retenu.proposition, ou celle-ci n'est plus dans la liste. */ * 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) { export function retenuAjuste(charge) {
const { retenu } = charge; const { retenu } = charge;
if (retenu === null) return false; 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. // dans le modèle pur et tenu par l'immuabilité de son entrée.
const evaluees = new WeakMap(); const evaluees = new WeakMap();
// Vrai quand chaque objet atteint depuis valeur est figé. Un objet figé ne /** Vrai quand chaque objet atteint depuis valeur est figé. Un objet figé ne
// redevient jamais modifiable : vrai une fois, vrai pour toujours. * redevient jamais modifiable : vrai une fois, vrai pour toujours. */
function figeePartout(valeur) { export function figeePartout(valeur) {
if (valeur === null || typeof valeur !== 'object') return true; if (valeur === null || typeof valeur !== 'object') return true;
if (!Object.isFrozen(valeur)) return false; if (!Object.isFrozen(valeur)) return false;
for (const cle of Object.keys(valeur)) if (!figeePartout(valeur[cle])) 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 { texteLivre } from '../../demo/livrees.js';
import { CRITERES, classer } from '../../moteur/classement.js'; import { CRITERES, classer } from '../../moteur/classement.js';
import { indexerPlan, normaliser } from '../../moteur/configuration.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 { mesurer } from '../../moteur/indicateurs.js';
import { ecartsAuPlafondAPriori, minimumAtteint, troisChiffres } from '../../moteur/manque.js'; import { ecartsAuPlafondAPriori, minimumAtteint, troisChiffres } from '../../moteur/manque.js';
import { plafondsAPriori, plafondsRealises } from '../../moteur/plafond.js'; import { plafondsAPriori, plafondsRealises } from '../../moteur/plafond.js';
@ -34,7 +35,7 @@ import {
GROUPES_COLONNES, GROUPES_COLONNES,
TAILLE_PAGE, TAILLE_PAGE,
changerDePage, changerDePage,
colonneDuCritere, colonneDeValeur,
demandeConfirmation, demandeConfirmation,
evaluation, evaluation,
explicationDeColonne, explicationDeColonne,
@ -123,6 +124,49 @@ const DEUX_COLLEGUES = [
[[1, 2, 6], [3, 4, 5]], [[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 // 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 // leurs collisions (cumulées, distinctes), au même plus grand écart au
// plafond a priori : ceux de l'épreuve de la table d'évaluation. // 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 « — »', () => { 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 table = evaluer(generee(chargeDemo('petite-conflit'), { graine: 5, nombre: 3, arret: 400 }));
const vue = tableau(table); 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.equal(vue.lignes.length, 3);
assert.deepEqual(vue.lignes.map(({ id }) => id), table.propositions.map(({ id }) => id)); assert.deepEqual(vue.lignes.map(({ id }) => id), table.propositions.map(({ id }) => id));
let vues = 0; let vues = 0;
@ -419,18 +464,40 @@ describe('tableau : l’ordre écrit (§ 5.7, § 17 point 4)', () => {
['identifiant', 'proposition'], ['identifiant', 'proposition'],
]); ]);
for (const morceau of vue.ordre[0]) { for (const morceau of vue.ordre[0]) {
if (morceau.colonne === null) assert.equal(morceau.critere, null, morceau.cle); if (morceau.colonne === null) {
else assert.equal(t(morceau.cle, morceau.details), morceau.critere === 'identifiant' ? "l'identifiant croissant" : t(`moteur.critere.${morceau.critere}`)); 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)); const noms = new Set(COLONNES.map(({ nom }) => nom));
assert.equal(CRITERES.length, 5); const valeurs = new Set();
for (const critere of CRITERES) assert.ok(noms.has(colonneDuCritere(critere)), critere); for (const mode of MODES_OBJECTIFS) {
assert.ok(noms.has(colonneDuCritere('identifiant'))); for (let masque = 0; masque < 16; masque += 1) {
assert.equal(colonneDuCritere('identifiant'), 'proposition'); const contraintes = {
assert.throws(() => colonneDuCritere('moyenneRencontres'), TypeError); 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', () => { 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], [ assert.deepEqual(vue.ordre[0], [
{ {
cle: 'propositions.ordre.saute', cle: 'propositions.ordre.saute',
details: { saute: 'ecartAuPlafondAPrioriMax', applique: [{ nom: 'excedentCollisions', poids: 1 }] }, details: { saute: 'ecartAuPlafondAPrioriMax', applique: [{ nom: 'excedentCollisions', poids: 1, valeurs: ['excedentCollisions'] }] },
critere: null, critere: null,
valeur: null,
colonne: 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 }))); const vue = tableau(evaluer(generee(aObjectifs(), { graine: 3, nombre: 2, arret: 200 })));
assert.equal( assert.equal(
phraseDe(vue.ordre[0]), phraseDe(vue.ordre[0]),
@ -511,12 +579,16 @@ describe('tableau : l’ordre écrit (§ 5.7, § 17 point 4)', () => {
"puis l'identifiant croissant.", "puis l'identifiant croissant.",
); );
const horsObjectif = vue.ordre[0].find(({ critere }) => critere === 'toursHorsObjectif'); const horsObjectif = vue.ordre[0].find(({ critere }) => critere === 'toursHorsObjectif');
assert.deepEqual(horsObjectif, { cle: 'moteur.critere.toursHorsObjectif', details: {}, critere: 'toursHorsObjectif', colonne: null }); assert.deepEqual(horsObjectif, {
assert.equal(colonneDuCritere('toursHorsObjectif'), null); cle: 'moteur.valeur.toursHorsObjectif',
assert.equal(colonneDuCritere('animateursNonRencontres'), null); 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 = [ const priorites = [
[e('toursHorsObjectif')], [e('toursHorsObjectif')],
[e('collisionsCumulees')], [e('collisionsCumulees')],
@ -529,13 +601,16 @@ describe('tableau : l’ordre écrit (§ 5.7, § 17 point 4)', () => {
assert.equal( assert.equal(
phraseDe(tableau(evaluer(charge)).ordre[0]), phraseDe(tableau(evaluer(charge)).ordre[0]),
"Ordre du classement, chaque critère au plus faible d'abord : tours hors objectif, puis collisions cumulées, " + "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.", "puis rencontres répétées, puis l'identifiant croissant.",
); );
const inconnu = tableau(evaluer(charge, { plafondsAPriori: null })); const inconnu = tableau(evaluer(charge, { plafondsAPriori: null }));
assert.deepEqual(inconnu.ordre[0][0].details, { assert.deepEqual(inconnu.ordre[0][0].details, {
saute: 'ecartAuPlafondAPrioriMax', 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( assert.equal(
phraseDe(inconnu.ordre[0]), 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)', () => { 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 « — »', () => { 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 }))); 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. // imposés d'une ligne comparable.
const [comparable] = vue.lignes; const [comparable] = vue.lignes;
assert.equal(vue.etendueRaisons, comparable.cellules.length + 1); 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', () => { 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'], ['effectifManque', 'bas'],
['ecartItineraireMax', 'bas'], ['ecartItineraireMax', 'bas'],
['ecartAuPlafondAPriori', 'bas'], ['ecartAuPlafondAPriori', 'bas'],
['jamaisServies', 'bas'],
['horsObjectifMax', 'bas'],
['toursHorsObjectif', 'bas'],
['toursSelonObjectif', 'grand'],
['animateursRencontres', 'grand'],
['animateursNonRencontres', 'bas'],
['certificat', null], ['certificat', null],
['imposes', null], ['imposes', null],
['commandes', 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); 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', () => { 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()]); assert.deepEqual(COLONNES.map(({ nom }) => nom), [...SENS.keys()]);
const textes = new Set(); const textes = new Set();
for (const { nom, cle, explication } of COLONNES) { 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', () => { 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 criteres = new Set(CRITERES.map(colonneDuCritere)); const valeurs = ['ecartAuPlafondAPrioriMax', 'excedentCollisions', 'collisionsCumulees', 'jamaisServies', 'horsObjectifMax']
assert.equal(criteres.size, 5); .concat(['toursHorsObjectif', 'rencontresRepeteesChoisies', 'retoursChoisis', 'animateursNonRencontres', 'redondance']);
const criteres = new Set(valeurs.map(colonneDeValeur));
assert.equal(criteres.size, 10);
for (const { nom } of COLONNES) { for (const { nom } of COLONNES) {
const texte = t(explicationDeColonne(nom)); const texte = t(explicationDeColonne(nom));
if (criteres.has(nom)) assert.match(texte, /critère du classement/i, 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); 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 }]) { for (const options of [{}, { plafondsAPriori: null }]) {
const vue = tableau(evaluer(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT), options)); const vue = tableau(evaluer(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT), options));
const designants = vue.ordre.flat().filter(({ colonne }) => colonne !== null); const designants = vue.ordre.flat().filter(({ colonne }) => colonne !== null);
assert.ok(designants.length >= 5); assert.ok(designants.length >= 5);
for (const { critere, colonne } of designants) { for (const { valeur, colonne } of designants) {
assert.equal(explicationDeColonne(colonne), explicationDeColonne(colonneDuCritere(critere)), critere); assert.equal(explicationDeColonne(colonne), explicationDeColonne(colonneDeValeur(valeur)), valeur);
assert.ok(t(explicationDeColonne(colonne)).length > 0, critere); 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', () => { test('explicationDeColonne lève pour un nom qui n’est pas une colonne', () => {
assert.equal(explicationDeColonne('rang'), 'propositions.explication.rang'); assert.equal(explicationDeColonne('rang'), 'propositions.explication.rang');
assert.throws(() => explicationDeColonne('redondance'), TypeError); 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']]); 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); const groupes = GROUPES_COLONNES.filter(({ cle }) => cle !== null);
assert.deepEqual( assert.deepEqual(
groupes.map(({ colonnes }) => colonnes), groupes.map(({ colonnes }) => colonnes),
@ -961,8 +1310,10 @@ describe('GROUPES_COLONNES : les en-têtes groupés du tableau', () => {
['rencontresRepetees', 'maxRencontresPaire', 'retoursChoisis'], ['rencontresRepetees', 'maxRencontresPaire', 'retoursChoisis'],
['appartenancesVues', 'totalRedondance', 'diversite'], ['appartenancesVues', 'totalRedondance', 'diversite'],
['manqueMax', 'effectifManque', 'ecartItineraireMax', 'ecartAuPlafondAPriori'], ['manqueMax', 'effectifManque', 'ecartItineraireMax', 'ecartAuPlafondAPriori'],
SIXIEME,
], ],
); );
assert.equal(groupes.at(-1).cle, 'propositions.groupe.objectifs');
const enTetes = new Set(COLONNES.map(({ cle }) => t(cle))); const enTetes = new Set(COLONNES.map(({ cle }) => t(cle)));
for (const { cle } of groupes) { for (const { cle } of groupes) {
assert.match(cle, /^propositions\.groupe\.[a-z]+$/u); 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 // page. Une frappe dans le champ, ou Échap, retire le
// complément d'un refus avec le refus que le champ retire. // 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 // Supprimer une table qui porte une réservation, un titre ou un objectif
// fenêtre à confirmer, qui les nomme (bilanSuppressionTable) ; une table // ouvre la fenêtre à confirmer, qui les nomme (bilanSuppressionTable) ;
// nue part sans fenêtre. // une table nue part sans fenêtre.
import { getContext } from 'svelte'; //
// 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 { on } from 'svelte/events';
import { bilanSuppressionTable } from '../../application/bilans.js'; import { bilanSuppressionTable } from '../../application/bilans.js';
import { lireEntier } from '../../application/capacite.js'; import { lireEntier } from '../../application/capacite.js';
import { exporterObjectifs } from '../../application/csv.js';
import { ErreurCommande } from '../../application/erreurs.js'; import { ErreurCommande } from '../../application/erreurs.js';
import Carte from '../primitives/Carte.svelte'; import Carte from '../primitives/Carte.svelte';
import Champ from '../primitives/Champ.svelte'; import Champ from '../primitives/Champ.svelte';
@ -34,7 +61,18 @@
import Confirmation from '../primitives/Confirmation.svelte'; import Confirmation from '../primitives/Confirmation.svelte';
import Icone from '../primitives/Icone.svelte'; import Icone from '../primitives/Icone.svelte';
import Pastille from '../primitives/Pastille.svelte'; import Pastille from '../primitives/Pastille.svelte';
import Refus from '../primitives/Refus.svelte';
import { effetDe } from '../raccourcis.js'; import { effetDe } from '../raccourcis.js';
import ChoixRegle from './ChoixRegle.svelte';
import {
basculerTable,
etiquettesConnues,
nomExportObjectifs,
objectifsDeLaTable,
optionsRegle,
regleChoisie,
regleParDefaut,
} from './objectifs.js';
import { import {
annonceSuppression, annonceSuppression,
avertissementsNommes, avertissementsNommes,
@ -51,9 +89,27 @@
const t = getContext('t'); const t = getContext('t');
const seance = getContext('seance'); const seance = getContext('seance');
const facade = getContext('facade');
const uid = $props.id();
const idConnues = `${uid}-etiquettes`;
const tables = $derived(tablesParNumero(charge)); const tables = $derived(tablesParNumero(charge));
const comptes = $derived(comptesTables(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 : // Le complément de chaque ligne, par identifiant de table :
// { revision, retires, avertissements }. La fenêtre de suppression // { revision, retires, avertissements }. La fenêtre de suppression
@ -114,6 +170,77 @@
suppression = null; 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 // 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. // de son refus : le champ retire le refus au même instant.
const suivreSaisie = (id) => (element) => { const suivreSaisie = (id) => (element) => {
@ -129,12 +256,76 @@
}; };
</script> </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> <Carte titre={t('tables.liste.titre')} sousTitre={t('tables.liste.comptes', comptes)} niveau={3} plein>
{#snippet actions()} {#snippet actions()}
<Commande cle="tables.creer.ronde" nom="creerTableRonde" executer={() => seance.executer('creerTable', { forme: 'ronde' })} /> <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.creer.carree" nom="creerTableCarree" executer={() => seance.executer('creerTable', { forme: 'carree' })} />
<Commande cle="tables.objectifs.exporter" nom="exporterObjectifs" executer={exporterLesObjectifs} />
{/snippet} {/snippet}
<datalist id={idConnues}>
{#each connues as motCle (motCle)}
<option value={motCle}></option>
{/each}
</datalist>
{#if tables.length === 0} {#if tables.length === 0}
<p class="aucune">{t('tables.liste.aucune')}</p> <p class="aucune">{t('tables.liste.aucune')}</p>
{:else} {:else}
@ -142,6 +333,9 @@
{#each tables as table (table.id)} {#each tables as table (table.id)}
{@const etat = etatTable(table, charge.evenement.siegesParDefaut)} {@const etat = etatTable(table, charge.evenement.siegesParDefaut)}
{@const complement = complementDe(table.id)} {@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}> <li data-table={table.id}>
<div class="entete"> <div class="entete">
<span class="numero"><span>{t('tables.liste.table')}</span> <span>{table.numero}</span></span> <span class="numero"><span>{t('tables.liste.table')}</span> <span>{table.numero}</span></span>
@ -194,6 +388,27 @@
</div> </div>
{/each} {/each}
{/if} {/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> </li>
{/each} {/each}
</ul> </ul>
@ -319,4 +534,82 @@
.texte p { .texte p {
margin: 0; 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> </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) --> License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script> <script>
// La section des tables et des tours (§ 4, § 6, § 2.1 étapes 4 et 5) : la // La section des tables et des tours (§ 4, § 4.5, § 6, § 2.1 étapes 4 et
// carte des réglages — le nombre de places par défaut et l'énoncé de son // 5) : la carte des réglages — le nombre de places par défaut et l'énoncé
// changement, le nombre de tours —, puis la carte de la liste des tables. // 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 // 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 // et les donne à ses parties ; chaque geste passe par la séance, et
// chaque refus se pose là où il a eu lieu. // chaque refus se pose là où il a eu lieu.
@ -12,6 +13,7 @@
import Carte from '../primitives/Carte.svelte'; import Carte from '../primitives/Carte.svelte';
import Defaut from './Defaut.svelte'; import Defaut from './Defaut.svelte';
import ListeTables from './ListeTables.svelte'; import ListeTables from './ListeTables.svelte';
import RegleObjectifs from './RegleObjectifs.svelte';
import Tours from './Tours.svelte'; import Tours from './Tours.svelte';
const t = getContext('t'); const t = getContext('t');
@ -26,6 +28,7 @@
<div class="reglages"> <div class="reglages">
<Defaut charge={$seance.charge} revision={$seance.evenement.revision} /> <Defaut charge={$seance.charge} revision={$seance.evenement.revision} />
<Tours charge={$seance.charge} /> <Tours charge={$seance.charge} />
<RegleObjectifs charge={$seance.charge} />
</div> </div>
</Carte> </Carte>
<ListeTables charge={$seance.charge} revision={$seance.evenement.revision} /> <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 { flushSync, mount, tick, unmount } from 'svelte';
import '../jetons.css'; import '../jetons.css';
import { controlerCapacite } from '../../application/capacite.js'; import { controlerCapacite } from '../../application/capacite.js';
import { exporterObjectifs } from '../../application/csv.js';
import { creerHorlogePilotee } from '../../application/horloge.js'; import { creerHorlogePilotee } from '../../application/horloge.js';
import { TABLES_APPLICATION } from '../../application/libelles.js'; import { TABLES_APPLICATION } from '../../application/libelles.js';
import { nomAffiche } from '../../application/personnes.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 // 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 // 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' } = {}) { async function preparer(charge, { mode = 'ecriture' } = {}) {
const enregistres = [];
const facade = {
async enregistrerSous(nomPropose, octets) {
enregistres.push({ nomPropose, octets });
return nomPropose;
},
};
const fs = creerFichiersSimules(); const fs = creerFichiersSimules();
fs.deposerDossier('documents'); fs.deposerDossier('documents');
fs.deposer('documents', `${BASE}${SUFFIXES.etat}`, serialiser(charge, { revision: 1, produitVersion: VERSION.affichee })); 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], ['t', t],
['horloge', horloge], ['horloge', horloge],
['ui', ui], ['ui', ui],
['facade', facade],
]); ]);
const cible = document.createElement('div'); const cible = document.createElement('div');
document.body.prepend(cible); document.body.prepend(cible);
const composant = mount(SectionTables, { target: cible, props: {}, context: contexte }); const composant = mount(SectionTables, { target: cible, props: {}, context: contexte });
flushSync(); flushSync();
montes.push({ composant, cible }); montes.push({ composant, cible });
return { fs, seance, ui, cible }; return { fs, seance, ui, cible, enregistres };
} }
const section = (m) => m.cible.querySelector('[data-section="tables"]'); 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')); 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 // 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 // placements s'avertit par la séance ; sinon la fenêtre à confirmer
// nomme, avant toute écriture, les réservations « tour désigné » qu'elle // nomme, avant toute écriture, les réservations « tour désigné » qu'elle
// détruit — personne et table — et les placements qui perdent des tours // détruit — personne et table —, les objectifs de table des tours retirés
// (bilanTours, annonceTours). Annuler la ferme et remonte le champ, qui rend la valeur // — table, tour et étiquettes (§ 4.3, A3) — et les placements qui perdent
// enregistrée ; Confirmer exécute le changement, une entrée. // 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 { getContext } from 'svelte';
import { bilanTours } from '../../application/bilans.js'; import { bilanTours } from '../../application/bilans.js';
import { lireEntier } from '../../application/capacite.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 * 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é » * appliqué (§ 2.1 étape 5, § 4.3) : il détruit une réservation « tour
* au-delà, ou, pour une baisse, il laisse en dérive un placement engendré, * désigné » ou un objectif de table d'un tour au-delà, ou, pour une baisse,
* entier mais qui porte des tours que l'événement n'a plus. Une hausse ne * il laisse en dérive un placement engendré, entier mais qui porte des
* détruit rien : les placements qu'elle laisse en dérive restent entiers, * tours que l'événement n'a plus. Une hausse ne détruit rien : les
* et la séance en avertit (DERIVE_NOUVELLE) sans fenêtre (§ 5.9). * 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 {ReturnType<import('../../application/bilans.js').bilanTours>} bilan
* @param {number} avant le nombre de tours enregistré * @param {number} avant le nombre de tours enregistré
@ -169,6 +170,7 @@ export function surchargesPerdues(charge) {
*/ */
export const annonceTours = (bilan, avant, apres) => export const annonceTours = (bilan, avant, apres) =>
bilan.reservationsDetruites.length > 0 || bilan.reservationsDetruites.length > 0 ||
bilan.objectifsDetruits.length > 0 ||
(apres < avant && (bilan.propositionsTouchees.length > 0 || bilan.retenuTouche)); (apres < avant && (bilan.propositionsTouchees.length > 0 || bilan.retenuTouche));
// Les placements qu'un changement laisse en dérive : chaque proposition, // 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: {} }] : []), ...(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 * Ce que nomme la fenêtre d'un changement de tours (§ 2.1 étape 5, A3) :
* réservation détruite, sa personne et le numéro de sa table, dans l'ordre * chaque réservation détruite, sa personne et le numéro de sa table, puis
* du bilan, puis les placements touchés. * 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 {Object} charge
* @param {ReturnType<import('../../application/bilans.js').bilanTours>} bilan * @param {ReturnType<import('../../application/bilans.js').bilanTours>} bilan
* @returns {Array<{cle: string, details: Object}>} * @returns {Array<{cle: string, details: Object}>}
* @throws {TypeError} un objectif du bilan que la charge ne porte pas
*/ */
export function elementsTours(charge, bilan) { export function elementsTours(charge, bilan) {
return [ return [
@ -193,18 +207,23 @@ export function elementsTours(charge, bilan) {
cle: 'tables.tours.reservation', cle: 'tables.tours.reservation',
details: { nom: nomDe(charge, participant), numero: numeroDe(charge.tables, table), tour }, 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), ...touches(bilan),
]; ];
} }
/** Vrai quand supprimer la table détruit une réservation ou un titre. */ /** 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; 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, * 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 * puis ses objectifs, leur tour — null pour la soirée — et leurs
* nue, que la suppression ne demande pas de confirmer. * é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 {Object} charge
* @param {ReturnType<import('../../application/bilans.js').bilanSuppressionTable>} bilan * @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 }, details: { nom: nomDe(charge, participant), portee, tour, siege },
})), })),
...bilan.titres.map(({ siege, libelle }) => ({ cle: 'tables.suppression.titre', details: { siege, libelle } })), ...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), ...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)', () => { 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', () => { test('siegesRetiresNommes : le siège 8 et son titre, puis le siège 7 et Iris Ombrelle, dans cet ordre', () => {
const charge = chargeDe({ 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-conversion', 'plan : une conversion offerte par une liste de travail', null],
['data-corbeille', 'accueil : la corbeille', ['']], ['data-corbeille', 'accueil : la corbeille', ['']],
['data-corbeille-dossier', 'accueil : un dossier de la corbeille', null], ['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-defaut', 'tables : le réglage du défaut de places', ['']],
['data-demonstration', 'accueil : une démonstration, par sa clé', null], ['data-demonstration', 'accueil : une démonstration, par sa clé', null],
['data-derive', 'événement : un placement en dérive, par son identifiant', 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', 'événement : un état du plan dans la tête', null],
['data-etat-table', 'tables : l’état d’une table', ['']], ['data-etat-table', 'tables : l’état d’une table', ['']],
['data-etendue', 'propositions : l’étendue d’une page du classement', ['']], ['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-evenement', 'accueil : une ligne de la liste, par sa base', null],
['data-evidence', 'plan : la personne mise en évidence', null], ['data-evidence', 'plan : la personne mise en évidence', null],
['data-fantome', 'plan : le fantôme d’une personne glissée', ['']], ['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-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-fusion', 'import : un groupe d’orthographes fondu', ['']],
['data-fusions', 'import : les groupes d’orthographes', ['']], ['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', 'participants : l’en-tête d’un groupe, par sa clé', null],
['data-groupe-compte', 'participants : le compte d’un groupe', ['']], ['data-groupe-compte', 'participants : le compte d’un groupe', ['']],
['data-groupe-conflits', 'participants : la marque de conflit 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-hors-classement', 'propositions : les lignes hors classement', ['']],
['data-issue', 'génération : l’issue de la dernière génération', ['']], ['data-issue', 'génération : l’issue de la dernière génération', ['']],
['data-legende', 'plan : la légende des conflits', ['']], ['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-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-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], ['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-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', 'é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-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-motif', 'import : le motif d’une ligne refusée', ['']],
['data-multiligne', 'import : une ligne sur plusieurs lignes de texte', null], ['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-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-nom-imprime', 'aperçu : le nom entier d’une personne sur une liste imprimée', ['']],
['data-nombre', 'import : un nombre affiché', ['']], ['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-ordre', 'propositions : la phrase de l’ordre', ['']],
['data-orthographe', 'import : une orthographe d’un groupe', ['']], ['data-orthographe', 'import : une orthographe d’un groupe', ['']],
['data-pages', 'propositions : une nav des pages du classement, haut ou bas', null], ['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', 'plan : un panneau', ['place', 'table']],
['data-panneau-diagnostic', 'plan : le panneau du diagnostic', ['']], ['data-panneau-diagnostic', 'plan : le panneau du diagnostic', ['']],
['data-papier', 'le papier de la machine : la question, ou le papier choisi', ['choisi', 'question']], ['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-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-place-vide', 'aperçu : une place vide de la feuille par table', ['']],
['data-plan', 'plan : le <svg> du plan', ['']], ['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-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-proposition', 'propositions : une ligne, par son identifiant', null],
['data-question', 'une question posée : dossier de travail ou durée longue', null], ['data-question', 'une question posée : dossier de travail ou durée longue', null],
['data-rapport', 'import : le rapport', ['']], ['data-rapport', 'import : le rapport', ['']],
@ -143,6 +154,7 @@ const ENTREES = [
['data-refus-realigner', 'tables : le refus du réalignement', ['']], ['data-refus-realigner', 'tables : le refus du réalignement', ['']],
['data-refusees', 'import : les lignes refusées', ['']], ['data-refusees', 'import : les lignes refusées', ['']],
['data-reglages', 'génération : les réglages', ['']], ['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-remede', 'diagnostic : un remède', ['']],
['data-reserve', 'plan : la réserve d’un tour', null], ['data-reserve', 'plan : la réserve d’un tour', null],
['data-retenu', 'propositions : la ligne du retenu', 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 { ErreurAnnulee } from '../../src/application/generation.js';
import { texteLivre } from '../../src/demo/livrees.js'; import { texteLivre } from '../../src/demo/livrees.js';
import { SECTIONS } from '../../src/interface/evenement/sections.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 { rechercher } from '../../src/moteur/recherche.js';
import { serialiser } from '../../src/stockage/canonique.js'; import { serialiser } from '../../src/stockage/canonique.js';
import { analyser, capacite, configurationDepuisCharge } from '../../src/stockage/document.js'; import { analyser, capacite, configurationDepuisCharge } from '../../src/stockage/document.js';
@ -652,6 +653,29 @@ export const ECRANS = Object.freeze([
return m.racine; 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', nom: 'participants.groupes',
mode: 'ecriture', mode: 'ecriture',
@ -732,6 +756,27 @@ export const ECRANS = Object.freeze([
return m.racine; 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', nom: 'evenement.avertissements',
mode: 'lecture', mode: 'lecture',
@ -759,6 +804,38 @@ export const ECRANS = Object.freeze([
return m.racine; 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', nom: 'propositions.critereSurvole',
mode: 'lecture', 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 ». // remèdes disent qu'un changement « la rend » ou « la rendent évitable ».
// Ailleurs, « évitable » nommerait le second segment du manque (§ 12.4), que // Ailleurs, « évitable » nommerait le second segment du manque (§ 12.4), que
// le logiciel n'écrit pas. // 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 = [ const EXCEPTIONS_DE_CONTENU = [
{ {
mot: 'évitable', mot: 'évitable',
@ -364,8 +370,17 @@ const EXCEPTIONS_DE_CONTENU = [
forme: /\bla rend(?:ent)? evitable\b/g, forme: /\bla rend(?:ent)? evitable\b/g,
nom: 'diagnostic.remede.* « la rend(ent) évitable »', 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é, // 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 // plan.refus.fermer — : une fonction la passe à texte, ou la compare à un
// détail, et ne l'affiche pas. // 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'], ['plan.segment', 'la rend évitable'],
['diagnostic.remede.inevitable', 'iné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.autre : évitable « la place devient évitable »',
'diagnostic.remede.reste : évitable « la rend évitable, part évitable »', 'diagnostic.remede.reste : évitable « la rend évitable, part évitable »',
'diagnostic.titre : évitable « un tabouret la rend évitable »', 'diagnostic.titre : évitable « un tabouret la rend évitable »',
'plan.segment : évitable « la rend évitable »', 'plan.segment : évitable « la rend évitable »',
]); ]);
const sansRemede = new Map([['diagnostic.remede.un', 'ajouter une table']]); 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);
}
}); });
}); });