[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
|
|
@ -539,7 +539,11 @@ réglage du poste, pour la séance, automatique par défaut (étape 9).
|
|||
| Nouveaux voisins à chaque tour | Génération | cochée |
|
||||
| Nouvelle table à chaque tour | Génération | cochée |
|
||||
| Varier les appartenances rencontrées | Génération | cochée |
|
||||
| Respecter les objectifs des tables | Génération | cochée |
|
||||
| Rencontrer le plus d'animateurs différents | Génération | décochée |
|
||||
| Attribuer les sièges | Génération | décochée |
|
||||
| Objectifs des tables : chercher d'abord | Génération | le plus de places servies |
|
||||
| Ordre des priorités | Génération | écart au plafond a priori, puis excédent de collisions, puis collisions cumulées, puis tours hors objectif, puis rencontres répétées et retours, puis animateurs non rencontrés, puis redondance d'appartenance |
|
||||
| Cible de la somme de redondance | Propositions | 1 000 |
|
||||
|
||||
<!-- engendre:fin reglages -->
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 102 KiB |
|
|
@ -307,15 +307,22 @@ export function tableau(entetes, lignes) {
|
|||
/**
|
||||
* Le tableau des réglages (§ 19.8) : chaque entrée de la définition, dans
|
||||
* son ordre — son libellé, qui porte l'unité d'un champ, l'écran qui la
|
||||
* montre, sa valeur par défaut.
|
||||
* montre, sa valeur par défaut. Celle d'un choix est le libellé de sa valeur,
|
||||
* <etiquette>.<valeur> ; celle de l'ordre des priorités, ses paliers dans
|
||||
* l'ordre, séparés par « puis », chaque critère sous son nom complet
|
||||
* (mobilier.critere.<nom>), ceux d'un palier partagé joints par « avec ».
|
||||
*
|
||||
* @param {ReadonlyArray<import('../src/interface/generation/reglages.js').EntreeReglage>} [definition]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function tableauReglages(definition = REGLAGES_GENERATION) {
|
||||
const defaut = ({ genre, defaut: valeur, cle }) => {
|
||||
const defaut = ({ genre, defaut: valeur, cle, etiquette }) => {
|
||||
if (genre === 'champ') return nombre(valeur);
|
||||
if (genre === 'bascule') return valeur ? 'cochée' : 'décochée';
|
||||
if (genre === 'choix') return t(`${etiquette}.${valeur}`);
|
||||
if (genre === 'ordre') {
|
||||
return valeur.map((palier) => palier.map(({ critere }) => t(`mobilier.critere.${critere}`)).join(' avec ')).join(', puis ');
|
||||
}
|
||||
throw new RangeError(`reglages : genre inconnu ${JSON.stringify(genre)} pour ${cle}`);
|
||||
};
|
||||
return tableau(
|
||||
|
|
|
|||
|
|
@ -314,10 +314,28 @@ describe('documentation : les producteurs, chacun sur sa source unique (§ 19.8)
|
|||
test('reglages : une ligne par entrée de la définition, son libellé, son écran et son défaut ; une entrée ajoutée paraît', () => {
|
||||
const lignes = lignesDuTableau(tableauReglages());
|
||||
assert.equal(lignes.length, REGLAGES_GENERATION.length);
|
||||
// Le défaut écrit de chaque genre : un nombre, une case ; le libellé du
|
||||
// mode par défaut et les paliers de l'ordre par défaut, écrits en toutes
|
||||
// lettres.
|
||||
const ecrit = {
|
||||
champ: ({ defaut }) => nombre(defaut),
|
||||
bascule: ({ defaut }) => (defaut ? 'cochée' : 'décochée'),
|
||||
choix: () => 'le plus de places servies',
|
||||
ordre: () =>
|
||||
'écart au plafond a priori, puis excédent de collisions, puis collisions cumulées, puis tours hors objectif, ' +
|
||||
"puis rencontres répétées et retours, puis animateurs non rencontrés, puis redondance d'appartenance",
|
||||
};
|
||||
REGLAGES_GENERATION.forEach((entree, rang) => {
|
||||
const attendu = entree.genre === 'champ' ? nombre(entree.defaut) : entree.defaut ? 'cochée' : 'décochée';
|
||||
assert.deepEqual(lignes[rang], [t(entree.etiquette), t(`coquille.section.${entree.section}`), attendu]);
|
||||
assert.deepEqual(lignes[rang], [t(entree.etiquette), t(`coquille.section.${entree.section}`), ecrit[entree.genre](entree)]);
|
||||
});
|
||||
// Sept bascules, le mode et l'ordre (§ 5.3, § 5.7).
|
||||
assert.equal(REGLAGES_GENERATION.filter(({ genre }) => genre === 'bascule').length, 7);
|
||||
const ordre = REGLAGES_GENERATION.find(({ genre }) => genre === 'ordre');
|
||||
const partage = { ...ordre, defaut: [[{ critere: 'excedentCollisions', poids: 2 }, { critere: 'redondance', poids: 3 }], [{ critere: 'collisionsCumulees', poids: 1 }]] };
|
||||
assert.equal(
|
||||
lignesDuTableau(tableauReglages([partage]))[0][2],
|
||||
`${t('mobilier.critere.excedentCollisions')} avec ${t('mobilier.critere.redondance')}, puis ${t('mobilier.critere.collisionsCumulees')}`,
|
||||
);
|
||||
const ajoutee = { ...REGLAGES_GENERATION[0], etiquette: 'generation.reglage.graine', defaut: 4_294_967_295 };
|
||||
const avecAjout = lignesDuTableau(tableauReglages([...REGLAGES_GENERATION, ajoutee]));
|
||||
assert.deepEqual(avecAjout.at(-1), [t('generation.reglage.graine'), t('coquille.section.generation'), '4\u{202F}294\u{202F}967\u{202F}295']);
|
||||
|
|
|
|||
|
|
@ -49,6 +49,11 @@ import { configurationDe } from './configuration.js';
|
|||
|
||||
export { CIBLE_REDONDANCE_PAR_DEFAUT, cibleRedondance };
|
||||
|
||||
// Les modes des objectifs, le défaut en tête, et les cases qui font compter
|
||||
// chaque critère, tels que le registre les tient (contrainte 35) :
|
||||
// l'interface, qui n'importe pas le moteur, les lit ici.
|
||||
export { CASES, MODES_OBJECTIFS } from '../moteur/criteres.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} LigneEvaluation
|
||||
* @property {number} id celui de la proposition ; retenu.proposition pour le retenu
|
||||
|
|
|
|||
|
|
@ -1245,8 +1245,11 @@ describe("libellés : l'ordre des priorités et le mode des objectifs (§ 5.7,
|
|||
assert.equal(libelle('POIDS_HORS_DOMAINE', { valeur: 2.5, remede: null }), "Un poids est un nombre entier de 1 à 100 : 2,5 ne l'est pas.");
|
||||
assert.equal(libelle('POIDS_HORS_DOMAINE', { valeur: Number.NaN, remede: null }), 'Un poids est un nombre entier de 1 à 100.');
|
||||
const partage = libelle('OBJECTIFS_PALIER_PARTAGE', { mode: 'minimum', palier: 1, remede: null });
|
||||
for (const motif of [/« le minimum, puis le total »/, /deux valeurs l'une après l'autre/, /« tours hors objectif »/, /palier 2\b/, /séparer/i, /reste celui d'avant/]) {
|
||||
for (const motif of [/^Ce mode compare deux valeurs l'une après l'autre/, /« tours hors objectif »/, /palier 2\b/, /séparer/i, /reste celui d'avant/]) {
|
||||
assert.match(partage, motif);
|
||||
}
|
||||
// Posé contre le groupe du mode, il ne nomme pas le mode autrement que
|
||||
// le bouton choisi.
|
||||
assert.doesNotMatch(partage, /minimum|total/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -192,12 +192,19 @@ export const TABLE = new Map([
|
|||
({ valeur }) =>
|
||||
`Un poids est un nombre entier de 1 à ${POIDS_MAX}` + (Number.isFinite(valeur) ? ` : ${nombreEcrit(valeur)} ne l'est pas.` : '.'),
|
||||
],
|
||||
// Le refus se pose contre le groupe du mode, sous le libellé du bouton
|
||||
// choisi : il dit « ce mode » sans le renommer, l'écran et le journal ne
|
||||
// le nommant pas pareil (D22). Un mode inconnu lève.
|
||||
[
|
||||
'OBJECTIFS_PALIER_PARTAGE',
|
||||
({ mode, palier }, texte) =>
|
||||
`Le mode ${cite(nomMode(mode))} compare deux valeurs l'une après l'autre : les ` +
|
||||
`${cite(texte('mobilier.critere.toursHorsObjectif'))} s'y rangent seuls à leur palier, et partagent le palier ` +
|
||||
`${numeroDePalier(palier)}. Les séparer d'abord dans l'ordre des priorités : le mode reste celui d'avant.`,
|
||||
({ mode, palier }, texte) => {
|
||||
nomMode(mode);
|
||||
return (
|
||||
`Ce mode compare deux valeurs l'une après l'autre : les ` +
|
||||
`${cite(texte('mobilier.critere.toursHorsObjectif'))} s'y rangent seuls à leur palier, et partagent le palier ` +
|
||||
`${numeroDePalier(palier)}. Les séparer d'abord dans l'ordre des priorités : le mode reste celui d'avant.`
|
||||
);
|
||||
},
|
||||
],
|
||||
// Le nom complet de chaque critère de l'ordre, tel que l'éditeur l'écrit
|
||||
// (§ 5.7) : la phrase de l'ordre nomme, elle, la valeur qui classe
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ import { regleObjectifs } from '../stockage/document.js';
|
|||
import { lireEtiquettes } from '../stockage/etiquettes.js';
|
||||
import { refus } from './erreurs.js';
|
||||
|
||||
// La règle par défaut des objectifs de l'événement, que le stockage seul lit
|
||||
// (« ou » quand les réglages n'en portent pas) : l'interface, qui n'importe
|
||||
// pas le stockage, la lit ici.
|
||||
export { regleObjectifs };
|
||||
|
||||
// Deux chaînes comparées unité UTF-16 par unité : l'ordre où le fichier
|
||||
// d'état range une liste d'étiquettes (§ 8.9).
|
||||
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
|
|
|
|||
|
|
@ -281,6 +281,70 @@ describe('la grande démonstration (§ 15.1, § 19.3 capture 10)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('les planchers des objectifs (§ 5.6, A10, D16, D20)', () => {
|
||||
// Quarante personnes inventées qui portent « finance », une table de 8 qui
|
||||
// la vise pour la soirée, quatre tables de 8 sans objectif, quatre tours :
|
||||
// les planchers valent 128, 8 et 4 (A10). mode : celui des objectifs,
|
||||
// absent au défaut.
|
||||
function quaranteFinance(mode = null) {
|
||||
const charge = creerCharge({ id: 'evt-objectifs', nom: 'Soirée des Merles', siegesParDefaut: 8, tours: 4 });
|
||||
charge.participants = Array.from({ length: 40 }, (_, p) => ({
|
||||
id: p + 1,
|
||||
nom: `Lacasse${p + 1}`,
|
||||
prenom: 'Ondine',
|
||||
appartenance: null,
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
etiquettes: ['finance'],
|
||||
}));
|
||||
charge.tables = [1, 2, 3, 4, 5].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
|
||||
charge.tables[0].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }];
|
||||
if (mode !== null) charge.reglages.modeObjectifs = mode;
|
||||
charge.prochainsIds = { participant: 41, table: 6, proposition: 1 };
|
||||
return charge;
|
||||
}
|
||||
|
||||
// Les énoncés des objectifs rendus sous la racine : la valeur que porte
|
||||
// data-plancher-objectifs, et la phrase.
|
||||
const planchers = (racine) =>
|
||||
[...racine.querySelectorAll('[data-plancher-objectifs]')].map((li) => [li.dataset.plancherObjectifs, texteDe(li.querySelector('[data-phrase]'))]);
|
||||
|
||||
test('sous « le plus de places servies », le seul plancher des tours hors objectif, avec son remède, dans la section et le panneau du plan', async () => {
|
||||
const m = await ouvrirCharge(quaranteFinance());
|
||||
m.ui.allerA('diagnostic');
|
||||
expect(diagnostiquer(configurationDepuisCharge(charge(m))).objectifsInevitables).toEqual({ horsObjectif: 128, jamaisServies: 8, horsObjectifMax: 4 });
|
||||
await attendre(() =>
|
||||
expect(planchers(section(m))).toEqual([['toursHorsObjectif', t('diagnostic.objectifs.toursHorsObjectif', { plancher: 128 })]]),
|
||||
);
|
||||
const ligne = section(m).querySelector('[data-plancher-objectifs]');
|
||||
expect(ligne.dataset.enonce).toBe('diagnostic.objectifs.toursHorsObjectif');
|
||||
expect(texteDe(ligne.querySelector('[data-remede]'))).toBe(t('diagnostic.remede.objectifs'));
|
||||
expect(visible(ligne.querySelector('[data-phrase]'))).toBe(true);
|
||||
m.ui.allerA('plan');
|
||||
await attendre(() => expect(planchers(panneau(m)).map(([grandeur]) => grandeur)).toEqual(['toursHorsObjectif']));
|
||||
});
|
||||
|
||||
test('sous « chacun au moins une fois », les personnes jamais servies d’abord, puis les tours hors objectif', async () => {
|
||||
const autre = await ouvrirCharge(quaranteFinance('chacun'));
|
||||
autre.ui.allerA('diagnostic');
|
||||
await attendre(() =>
|
||||
expect(planchers(section(autre))).toEqual([
|
||||
['jamaisServies', t('diagnostic.objectifs.jamaisServies', { plancher: 8 })],
|
||||
['toursHorsObjectif', t('diagnostic.objectifs.toursHorsObjectif', { plancher: 128 })],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('sans objectif, aucun plancher des objectifs ne s’écrit', async () => {
|
||||
const m = await demonstration('petite-conflit');
|
||||
m.ui.allerA('diagnostic');
|
||||
await attendre(() => expect(section(m).querySelectorAll('[data-enonce]').length).toBeGreaterThan(0));
|
||||
expect(section(m).querySelectorAll('[data-plancher-objectifs]')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('une seule appartenance (§ 15.4)', () => {
|
||||
test('la phrase et son remède ; en lecture, refusé avec « Modifier » ; en écriture, exécuté en une entrée', async () => {
|
||||
const m = await ouvrirCharge(chargeDouze(() => 'Club des Merles'));
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@
|
|||
// qui l'exécute par la séance ; un refus de la séance — mode lecture, plan
|
||||
// bloqué — s'affiche à côté du bouton, avec son propre remède.
|
||||
//
|
||||
// Les planchers des objectifs (A10) s'énoncent selon le mode réglé : ils
|
||||
// lisent l'état des critères, criteresDuDiagnostic, qui ne s'évalue que
|
||||
// lorsqu'un plancher des objectifs est positif. Chacun porte
|
||||
// data-plancher-objectifs, la valeur dont il donne le plancher.
|
||||
//
|
||||
// Une configuration que le moteur refuse n'a pas de diagnostic : son
|
||||
// code, traduit, se pose à sa place, jamais le message de l'erreur.
|
||||
import { getContext } from 'svelte';
|
||||
|
|
@ -35,7 +40,7 @@
|
|||
import Commande from '../primitives/Commande.svelte';
|
||||
import Pastille from '../primitives/Pastille.svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import { cartesDiagnostic, enonces, numeroDeCharge } from './phrases.js';
|
||||
import { cartesDiagnostic, criteresDuDiagnostic, enonces, numeroDeCharge } from './phrases.js';
|
||||
|
||||
let { compact = false } = $props();
|
||||
|
||||
|
|
@ -49,7 +54,11 @@
|
|||
const liste = $derived(
|
||||
resultat === null || resultat.diagnostic === null
|
||||
? []
|
||||
: enonces(resultat.diagnostic, { numeroDe: numeroDeCharge(charge), tours: charge.evenement.tours }),
|
||||
: enonces(resultat.diagnostic, {
|
||||
numeroDe: numeroDeCharge(charge),
|
||||
tours: charge.evenement.tours,
|
||||
criteres: criteresDuDiagnostic(charge, resultat.diagnostic),
|
||||
}),
|
||||
);
|
||||
const cartes = $derived(cartesDiagnostic(liste));
|
||||
// Le rang de teinte de chaque appartenance, sur tous les participants.
|
||||
|
|
@ -72,7 +81,7 @@
|
|||
</svelte:element>
|
||||
<ul aria-labelledby={id}>
|
||||
{#each carte.enonces as enonce, n (n)}
|
||||
<li data-enonce={enonce.cle}>
|
||||
<li data-enonce={enonce.cle} data-plancher-objectifs={enonce.grandeur}>
|
||||
<p class="enonce"><span data-phrase>{t(enonce.cle, enonce.details)}</span></p>
|
||||
{#if !compact && enonce.remede !== null}
|
||||
{@const action = actionDe(enonce.remede)}
|
||||
|
|
|
|||
|
|
@ -16,11 +16,19 @@
|
|||
// un texte.
|
||||
//
|
||||
// L'ordre est celui du § 5.6 — collisions et excédent, redondance,
|
||||
// animateurs, ancrage, écart d'itinéraire, manque de places —, précédé de ce
|
||||
// qui vide la séparation de son sens (§ 15.4, § 7.5) et suivi du plafond a
|
||||
// priori (§ 5.5) et des retours imposés (§ 5.4). Un plancher ne s'énonce que
|
||||
// strictement positif : un plancher nul laisserait croire qu'un plancher a
|
||||
// été calculé là où il n'y en a pas.
|
||||
// animateurs, ancrage, écart d'itinéraire, manque de places, objectifs hors
|
||||
// d'atteinte —, précédé de ce qui vide la séparation de son sens (§ 15.4,
|
||||
// § 7.5) et suivi du plafond a priori (§ 5.5) et des retours imposés
|
||||
// (§ 5.4). Un plancher ne s'énonce que strictement positif : un plancher nul
|
||||
// laisserait croire qu'un plancher a été calculé là où il n'y en a pas.
|
||||
//
|
||||
// Les planchers des objectifs (A10, D16) se calculent toujours ; l'énoncé
|
||||
// n'écrit que ceux des valeurs que le mode réglé compare, lues dans l'état
|
||||
// du critère des tours hors objectif (les valeurs de valeursQuiClassent,
|
||||
// telles qu'evaluer les rend), dans l'ordre où le classement les compare.
|
||||
// Un critère décoché ou sans objet ne compare rien : ses planchers se
|
||||
// taisent.
|
||||
import { evaluer } from '../../application/evaluation.js';
|
||||
|
||||
// Désactiver la séparation : le seul remède que réalise une commande. Un
|
||||
// objet neuf à chaque appel.
|
||||
|
|
@ -127,6 +135,51 @@ function placesManquantes({ placesManquantes: manquantes }) {
|
|||
return [{ cle: 'diagnostic.placesManquantes', details: { placesManquantes: manquantes }, remede: remede('diagnostic.remede.places') }];
|
||||
}
|
||||
|
||||
// Le nom du critère des objectifs dans l'état des critères, et, pour chaque
|
||||
// valeur qu'il peut comparer, le champ de objectifsInevitables qui en donne
|
||||
// le plancher.
|
||||
const CRITERE_DES_OBJECTIFS = 'toursHorsObjectif';
|
||||
const PLANCHERS_DES_OBJECTIFS = new Map([
|
||||
['toursHorsObjectif', 'horsObjectif'],
|
||||
['jamaisServies', 'jamaisServies'],
|
||||
['horsObjectifMax', 'horsObjectifMax'],
|
||||
]);
|
||||
|
||||
// Vrai quand un plancher des objectifs au moins est strictement positif.
|
||||
const unPlancherDesObjectifs = ({ objectifsInevitables }) =>
|
||||
[...PLANCHERS_DES_OBJECTIFS.values()].some((champ) => objectifsInevitables[champ] > 0);
|
||||
|
||||
// Le plancher de chaque valeur que le critère des objectifs compare, quand
|
||||
// il est strictement positif, dans l'ordre de ses valeurs ; rien quand le
|
||||
// critère manque à criteres, est décoché ou sans objet. grandeur nomme la
|
||||
// valeur, que l'écran pose en data-plancher-objectifs.
|
||||
function objectifs({ objectifsInevitables }, criteres) {
|
||||
const etat = criteres.find(({ nom }) => nom === CRITERE_DES_OBJECTIFS);
|
||||
if (etat === undefined || !etat.coche || etat.sansObjet) return [];
|
||||
return etat.valeurs.flatMap((grandeur) => {
|
||||
if (!PLANCHERS_DES_OBJECTIFS.has(grandeur)) throw new RangeError(`valeur des objectifs inconnue : ${JSON.stringify(grandeur)}`);
|
||||
const plancher = objectifsInevitables[PLANCHERS_DES_OBJECTIFS.get(grandeur)];
|
||||
if (plancher === 0) return [];
|
||||
return [{ cle: `diagnostic.objectifs.${grandeur}`, details: { plancher }, remede: remede('diagnostic.remede.objectifs'), grandeur }];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* L'état des critères que l'énoncé des objectifs lit (§ 5.3, D10, D20) :
|
||||
* celui qu'evaluer rend, sur la charge sans ses placements — aucune
|
||||
* proposition ne se mesure pour le lire. Quand aucun plancher des objectifs
|
||||
* n'est positif, il n'y a rien à lire : la liste vide, sans évaluation.
|
||||
* Ne modifie pas la charge.
|
||||
*
|
||||
* @param {Object} charge
|
||||
* @param {Object} diagnostic celui de diagnostiquerCharge pour cette charge
|
||||
* @returns {Array<{nom: string, coche: boolean, sansObjet: boolean, valeurs: string[]}>}
|
||||
*/
|
||||
export function criteresDuDiagnostic(charge, diagnostic) {
|
||||
if (!unPlancherDesObjectifs(diagnostic)) return [];
|
||||
return evaluer({ ...charge, propositions: [], retenu: null }, { plafondsAPriori: null }).criteres;
|
||||
}
|
||||
|
||||
// Le plafond a priori, une ligne par valeur de la répartition ; une
|
||||
// population vide n'en a aucun, et l'énoncé le dit par le tiret.
|
||||
function plafond({ plafondAPriori }) {
|
||||
|
|
@ -155,13 +208,16 @@ function imposes({ retoursImposes, ancragesImposantRetours }) {
|
|||
* le diagnostic.
|
||||
*
|
||||
* @param {Object} diagnostic celui que rend diagnostiquerCharge
|
||||
* @param {{numeroDe: (table: number) => number, tours: number}} options
|
||||
* @param {{numeroDe: (table: number) => number, tours: number,
|
||||
* criteres?: Array<{nom: string, coche: boolean, sansObjet: boolean, valeurs: string[]}>}} options
|
||||
* numeroDe : le numéro affiché d'un identifiant de table ; tours : le
|
||||
* nombre de tours de l'événement
|
||||
* nombre de tours de l'événement ; criteres : l'état des critères
|
||||
* (criteresDuDiagnostic), vide par défaut, et aucun plancher des
|
||||
* objectifs ne s'écrit alors
|
||||
* @returns {Array<{cle: string, details: Object, remede: null|{cle: string, details: Object,
|
||||
* commande?: string, arguments?: Object}}>}
|
||||
* commande?: string, arguments?: Object}, grandeur?: string}>}
|
||||
*/
|
||||
export function enonces(diagnostic, { numeroDe, tours }) {
|
||||
export function enonces(diagnostic, { numeroDe, tours, criteres = [] }) {
|
||||
return [
|
||||
...separation(diagnostic),
|
||||
...collisions(diagnostic, tours),
|
||||
|
|
@ -170,6 +226,7 @@ export function enonces(diagnostic, { numeroDe, tours }) {
|
|||
...ancrage(diagnostic, numeroDe),
|
||||
...ecartItineraire(diagnostic),
|
||||
...placesManquantes(diagnostic),
|
||||
...objectifs(diagnostic, criteres),
|
||||
...plafond(diagnostic),
|
||||
...imposes(diagnostic),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { texteLivre } from '../../demo/livrees.js';
|
|||
import { diagnostiquer } from '../../moteur/diagnostic.js';
|
||||
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { cartesDiagnostic, enonces, numeroDeCharge } from './phrases.js';
|
||||
import { cartesDiagnostic, criteresDuDiagnostic, enonces, numeroDeCharge } from './phrases.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const TIRET = '\u{2014}';
|
||||
|
|
@ -58,11 +58,28 @@ function chargeDe({ appartenances, capacites, tours = 4, ancres = [], separer =
|
|||
|
||||
const fois = (n, valeur) => Array(n).fill(valeur);
|
||||
|
||||
// Le diagnostic d'une charge, par l'application, et ses énoncés.
|
||||
/**
|
||||
* Une charge d'objectifs (§ 4.5, A10) : n personnes qui portent
|
||||
* « finance », une table 1 de 8 places qui la vise pour la soirée, puis des
|
||||
* tables sans objectif aux capacités données, quatre tours. mode : le mode
|
||||
* des objectifs, absent au défaut ; respecter faux décoche le critère.
|
||||
*/
|
||||
function chargeObjectifs(n, autres, { mode = null, respecter = true } = {}) {
|
||||
const charge = chargeDe({ appartenances: fois(n, null), capacites: [8, ...autres] });
|
||||
for (const participant of charge.participants) participant.etiquettes = ['finance'];
|
||||
charge.tables[0].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }];
|
||||
if (mode !== null) charge.reglages.modeObjectifs = mode;
|
||||
if (!respecter) charge.reglages.respecterObjectifs = false;
|
||||
return charge;
|
||||
}
|
||||
|
||||
// Le diagnostic d'une charge, par l'application, et ses énoncés, sous l'état
|
||||
// des critères que l'écran lit pour les objectifs.
|
||||
function lire(charge) {
|
||||
const { diagnostic, erreur } = diagnostiquerCharge(charge);
|
||||
assert.equal(erreur, null);
|
||||
return { diagnostic, liste: enonces(diagnostic, { numeroDe: numeroDeCharge(charge), tours: charge.evenement.tours }) };
|
||||
const criteres = criteresDuDiagnostic(charge, diagnostic);
|
||||
return { diagnostic, liste: enonces(diagnostic, { numeroDe: numeroDeCharge(charge), tours: charge.evenement.tours, criteres }) };
|
||||
}
|
||||
|
||||
const cles = (liste) => liste.map(({ cle }) => cle);
|
||||
|
|
@ -120,6 +137,9 @@ const CONFIGURATIONS = {
|
|||
petiteConflit: () => chargeDemo('petite-conflit'),
|
||||
grande: () => chargeDemo('grande'),
|
||||
grandeSansException: () => chargeDemo('grande-sans-exception'),
|
||||
// Dix personnes « finance », une table « finance » de 8, une table de 2
|
||||
// sans objectif, quatre tours (A10).
|
||||
objectifs: () => chargeObjectifs(10, [2]),
|
||||
};
|
||||
|
||||
// --- Chaque champ, son énoncé ------------------------------------------------------
|
||||
|
|
@ -239,6 +259,22 @@ const CHAMPS = [
|
|||
]
|
||||
: [],
|
||||
},
|
||||
{
|
||||
// Sous le mode par défaut, « le plus de places servies », le seul
|
||||
// plancher que le mode compare : les tours hors objectif.
|
||||
champ: 'objectifsInevitables',
|
||||
attendus: (d) =>
|
||||
d.objectifsInevitables.horsObjectif > 0
|
||||
? [
|
||||
{
|
||||
cle: 'diagnostic.objectifs.toursHorsObjectif',
|
||||
details: { plancher: d.objectifsInevitables.horsObjectif },
|
||||
remede: { cle: 'diagnostic.remede.objectifs', details: {} },
|
||||
grandeur: 'toursHorsObjectif',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
{
|
||||
champ: 'plafondAPriori',
|
||||
attendus: (d) =>
|
||||
|
|
@ -457,6 +493,83 @@ describe('enonces : les phrases du § 5.6', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('enonces : les planchers des objectifs (§ 5.6, A10, D16, D20)', () => {
|
||||
// Les énoncés des objectifs d'une liste : grandeur, plancher et texte.
|
||||
const desObjectifs = (liste) =>
|
||||
liste.filter(({ cle }) => cle.startsWith('diagnostic.objectifs.')).map((enonce) => [enonce.grandeur, enonce.details.plancher, texte(enonce)]);
|
||||
|
||||
test('dix personnes « finance », une table de 8, quatre tours : 8 tours hors objectif ; les deux autres planchers, nuls, se taisent sous chaque mode', () => {
|
||||
const { diagnostic, liste } = lire(chargeObjectifs(10, [2]));
|
||||
assert.deepEqual(diagnostic.objectifsInevitables, { horsObjectif: 8, jamaisServies: 0, horsObjectifMax: 1 });
|
||||
assert.deepEqual(desObjectifs(liste), [
|
||||
[
|
||||
'toursHorsObjectif',
|
||||
8,
|
||||
"au moins 8 tours hors objectif : les tables qui portent un objectif n'ont pas assez de places libres pour chaque tour possible",
|
||||
],
|
||||
]);
|
||||
// Sous « chacun au moins une fois », le plancher des personnes jamais
|
||||
// servies, nul, ne s'écrit pas.
|
||||
assert.deepEqual(desObjectifs(lire(chargeObjectifs(10, [2], { mode: 'chacun' })).liste).map(([grandeur]) => grandeur), ['toursHorsObjectif']);
|
||||
// Sous « l'équité d'abord », le plus de tours hors objectif d'une
|
||||
// personne vaut 1 : il s'écrit, avant les tours hors objectif.
|
||||
assert.deepEqual(desObjectifs(lire(chargeObjectifs(10, [2], { mode: 'minimum' })).liste), [
|
||||
['horsObjectifMax', 1, 'au moins une personne passe au moins 1 tour hors objectif, quel que soit le placement'],
|
||||
[
|
||||
'toursHorsObjectif',
|
||||
8,
|
||||
"au moins 8 tours hors objectif : les tables qui portent un objectif n'ont pas assez de places libres pour chaque tour possible",
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('quarante personnes « finance » : 128, 8 et 4 ; chaque mode écrit les planchers qu’il compare, dans l’ordre où il les compare, et jamais les autres', () => {
|
||||
const autres = fois(4, 8);
|
||||
const { diagnostic, liste } = lire(chargeObjectifs(40, autres));
|
||||
assert.deepEqual(diagnostic.objectifsInevitables, { horsObjectif: 128, jamaisServies: 8, horsObjectifMax: 4 });
|
||||
const grandeurs = (mode) => desObjectifs(lire(chargeObjectifs(40, autres, { mode })).liste).map(([grandeur, plancher]) => [grandeur, plancher]);
|
||||
assert.deepEqual(desObjectifs(liste).map(([grandeur, plancher]) => [grandeur, plancher]), [['toursHorsObjectif', 128]]);
|
||||
assert.deepEqual(grandeurs('chacun'), [
|
||||
['jamaisServies', 8],
|
||||
['toursHorsObjectif', 128],
|
||||
]);
|
||||
assert.deepEqual(grandeurs('minimum'), [
|
||||
['horsObjectifMax', 4],
|
||||
['toursHorsObjectif', 128],
|
||||
]);
|
||||
const [jamais] = desObjectifs(lire(chargeObjectifs(40, autres, { mode: 'chacun' })).liste);
|
||||
assert.equal(jamais[2], 'au moins 8 personnes jamais servies par une table à objectif, quel que soit le placement');
|
||||
for (const enonce of liste.filter(({ cle }) => cle.startsWith('diagnostic.objectifs.'))) {
|
||||
assert.deepEqual(enonce.remede, { cle: 'diagnostic.remede.objectifs', details: {} });
|
||||
assert.equal(typeof texte(enonce.remede), 'string');
|
||||
}
|
||||
});
|
||||
|
||||
test('le critère des objectifs décoché : aucun plancher des objectifs, le diagnostic les calcule toujours', () => {
|
||||
const { diagnostic, liste } = lire(chargeObjectifs(40, fois(4, 8), { mode: 'chacun', respecter: false }));
|
||||
assert.equal(diagnostic.objectifsInevitables.horsObjectif, 128);
|
||||
assert.deepEqual(desObjectifs(liste), []);
|
||||
});
|
||||
|
||||
test('criteresDuDiagnostic : sans plancher positif, aucune lecture des critères ; avec, l’état de chaque critère du registre', () => {
|
||||
const sans = CONFIGURATIONS.petite();
|
||||
assert.deepEqual(criteresDuDiagnostic(sans, diagnostiquerCharge(sans).diagnostic), []);
|
||||
const charge = chargeObjectifs(40, fois(4, 8), { mode: 'minimum' });
|
||||
const avant = structuredClone(charge);
|
||||
const criteres = criteresDuDiagnostic(charge, diagnostiquerCharge(charge).diagnostic);
|
||||
const objectifs = criteres.find(({ nom }) => nom === 'toursHorsObjectif');
|
||||
assert.deepEqual(objectifs, { nom: 'toursHorsObjectif', coche: true, sansObjet: false, valeurs: ['horsObjectifMax', 'toursHorsObjectif'] });
|
||||
assert.deepEqual(charge, avant);
|
||||
});
|
||||
|
||||
test('sans état des critères, enonces n’écrit aucun plancher des objectifs', () => {
|
||||
const charge = chargeObjectifs(40, fois(4, 8));
|
||||
const { diagnostic } = diagnostiquerCharge(charge);
|
||||
const liste = enonces(diagnostic, { numeroDe: numeroDeCharge(charge), tours: 4 });
|
||||
assert.deepEqual(desObjectifs(liste), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enonces : aux bords', () => {
|
||||
test('un événement neuf, zéro participant, zéro table : aucun plancher, le plafond a priori « — », jamais 0', () => {
|
||||
const { liste } = lire(chargeDe({ appartenances: [], capacites: [] }));
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@
|
|||
// moteur de rendu (§ 5.7, § 13.3) : tant que la charge de la séance ne
|
||||
// change pas, revenir à l'une ou l'autre section ne mesure rien de nouveau ;
|
||||
// une charge écrite se mesure une fois, et la meilleure proposition annoncée
|
||||
// la suit.
|
||||
// la suit. Une charge sans proposition n'a pas de table partagée : l'éditeur
|
||||
// des priorités y lit l'état des critères par une évaluation à lui, une fois
|
||||
// par charge écrite.
|
||||
//
|
||||
// Le module de l'évaluation est remplacé par ses propres fonctions,
|
||||
// espionnées (vi.mock, spy) : evaluer garde son effet, et ses appels se
|
||||
|
|
@ -109,8 +111,14 @@ describe('Génération et Propositions relisent une même évaluation (§ 5.7,
|
|||
const anciens = charge(m).propositions.map(({ id }) => id);
|
||||
const avantEcriture = appels();
|
||||
await m.seance.executer('effacerPropositions', {});
|
||||
flushSync();
|
||||
expect(appels(), 'la charge sans proposition, évaluée une fois pour l’état des critères').toBe(avantEcriture + 1);
|
||||
aller(m, 'participants');
|
||||
aller(m, 'generation');
|
||||
expect(appels(), 'la charge sans proposition, revisitée').toBe(avantEcriture + 1);
|
||||
const avantGeneration = appels();
|
||||
await enregistrer(m, { graine: 9, nombre: 2, arret: 300 });
|
||||
expect(appels(), 'la charge écrite, mesurée une fois').toBe(avantEcriture + 1);
|
||||
expect(appels(), 'la charge écrite, mesurée une fois').toBe(avantGeneration + 1);
|
||||
const attendu = rangUnAttendu(m);
|
||||
const apres = appels();
|
||||
expect(identifiant(m).textContent).toBe(attendu);
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import { fixerCadre, pointer, touche } from '../../../test/navigateur/entrees.js
|
|||
import { monterApp } from '../../../test/navigateur/montage.js';
|
||||
import { composantes, contraste, sousTheme } from '../../../test/navigateur/rendu.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { REGLAGES_GENERATION, valeurDuReglage } from './reglages.js';
|
||||
import Travailleur from './travailleur.js?worker&inline';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
|
|
@ -295,7 +296,7 @@ async function regler(m, reglages) {
|
|||
describe('les réglages (§ 5.7, § 5.10)', () => {
|
||||
test('exécuteur direct : quatre champs, chacun son libellé et son unité ; l’historique pré-rempli à 1 000 ; aucun champ de durée', async () => {
|
||||
const m = await demonstration('petite', { executeur: creerExecuteurDirect() });
|
||||
const entrees = [...section(m).querySelectorAll('input:not([type="checkbox"])')];
|
||||
const entrees = [...section(m).querySelectorAll('input:not([type="checkbox"]):not([type="radio"])')];
|
||||
expect(entrees.length).toBe(4);
|
||||
expect(entrees.map((entree) => entree.dataset.champ)).toEqual(CHAMPS);
|
||||
for (const cle of CHAMPS) {
|
||||
|
|
@ -386,24 +387,36 @@ describe('les réglages (§ 5.7, § 5.10)', () => {
|
|||
});
|
||||
|
||||
describe('les contraintes et l’attribution des sièges (§ 5.3)', () => {
|
||||
// Les cinq bascules, dans l'ordre du § 5.3.
|
||||
const BASCULES = ['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable', 'varierAppartenances', 'attribuerSieges'];
|
||||
// Les sept bascules, dans l'ordre du § 5.3 ; les deux cases facultatives
|
||||
// valent leur défaut tant que la charge ne les porte pas.
|
||||
const BASCULES = [
|
||||
'separerAppartenances',
|
||||
'nouveauxVoisins',
|
||||
'nouvelleTable',
|
||||
'varierAppartenances',
|
||||
'respecterObjectifs',
|
||||
'rencontrerAnimateurs',
|
||||
'attribuerSieges',
|
||||
];
|
||||
const NOMS_DU_SPEC = [
|
||||
'Séparer les appartenances',
|
||||
'Nouveaux voisins à chaque tour',
|
||||
'Nouvelle table à chaque tour',
|
||||
'Varier les appartenances rencontrées',
|
||||
'Respecter les objectifs des tables',
|
||||
"Rencontrer le plus d'animateurs différents",
|
||||
'Attribuer les sièges',
|
||||
];
|
||||
const valeurLue = (reglages, cle) => valeurDuReglage(reglages, REGLAGES_GENERATION.find((entree) => entree.cle === cle));
|
||||
|
||||
test('direct : cinq bascules sous leur nom du § 5.3 ; chacune, une entrée, son réglage seul', async () => {
|
||||
test('direct : sept bascules sous leur nom du § 5.3 ; chacune, une entrée, son réglage seul', async () => {
|
||||
const m = await demonstration('petite', { executeur: creerExecuteurDirect() });
|
||||
const cases = [...section(m).querySelectorAll('input[type="checkbox"]')];
|
||||
expect(cases.map((caseACocher) => caseACocher.dataset.champ)).toEqual([...BASCULES, 'coeursAutomatiques']);
|
||||
BASCULES.forEach((cle, rang) => {
|
||||
expect(etiquette(m, cle).textContent).toBe(t(`generation.contrainte.${cle}`));
|
||||
expect(t(`generation.contrainte.${cle}`)).toBe(NOMS_DU_SPEC[rang]);
|
||||
expect(bascule(m, cle).checked, cle).toBe(charge(m).reglages[cle]);
|
||||
expect(bascule(m, cle).checked, cle).toBe(valeurLue(charge(m).reglages, cle));
|
||||
});
|
||||
for (const cle of BASCULES) {
|
||||
const avant = structuredClone(charge(m).reglages);
|
||||
|
|
@ -412,8 +425,8 @@ describe('les contraintes et l’attribution des sièges (§ 5.3)', () => {
|
|||
await cliquer(m, bascule(m, cle));
|
||||
expect(revision(m), cle).toBe(r0 + 1);
|
||||
expect(journalDe(m).entrees.length, cle).toBe(entrees + 1);
|
||||
expect(charge(m).reglages, cle).toEqual({ ...avant, [cle]: !avant[cle] });
|
||||
expect(bascule(m, cle).checked, cle).toBe(!avant[cle]);
|
||||
expect(charge(m).reglages, cle).toEqual({ ...avant, [cle]: !valeurLue(avant, cle) });
|
||||
expect(bascule(m, cle).checked, cle).toBe(!valeurLue(avant, cle));
|
||||
}
|
||||
// La barre d'espace bascule aussi, par le même geste.
|
||||
const cle = BASCULES[0];
|
||||
|
|
|
|||
257
src/interface/generation/Priorites.svelte
Normal 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>
|
||||
|
|
@ -8,9 +8,16 @@
|
|||
// entrée de la section 'generation' de REGLAGES_GENERATION dans son ordre,
|
||||
// sous son libellé, à la valeur de la charge, et aucune d'une autre section ;
|
||||
// une définition passée par l'épreuve, une entrée ajoutée ou retirée, change
|
||||
// l'écran de même. Le texte attendu vient de la
|
||||
// même traduction que celle de l'application ; les noms d'épreuve sont
|
||||
// inventés.
|
||||
// l'écran de même.
|
||||
//
|
||||
// L'éditeur de l'ordre des priorités et le choix du mode des objectifs
|
||||
// (§ 5.3, § 5.7, A8, A11, A12, D23) s'éprouvent dans l'application montée
|
||||
// sur la petite démonstration, la section de la génération ouverte : chaque
|
||||
// geste, au pointeur réel ou au clavier, est une commande et une entrée ; les
|
||||
// gestes offerts sont ceux du modèle sous l'admission de la commande ; un
|
||||
// refus se pose contre la liste ou contre le groupe, et le choix refusé
|
||||
// revient à l'ancien mode. Le texte attendu vient de la même traduction que
|
||||
// celle de l'application ; les noms d'épreuve sont inventés.
|
||||
//
|
||||
// Le projet navigateur emploie expect, et test et describe de Vitest
|
||||
// (§ 14.8).
|
||||
|
|
@ -22,12 +29,18 @@ import { creerHorlogePilotee } from '../../application/horloge.js';
|
|||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerSeance } from '../../application/seance.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { CRITERES, MODES_OBJECTIFS } from '../../moteur/criteres.js';
|
||||
import { lireJournal } from '../../stockage/journal.js';
|
||||
import { SUFFIXES } from '../../stockage/noms.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { creerFichiersSimules } from '../../../test/fichiers_simules.js';
|
||||
import { couleurDeJeton } from '../../../test/navigateur/captures.js';
|
||||
import { couleurDeJeton, releverTextes } from '../../../test/navigateur/captures.js';
|
||||
import { fixerCadre, pointer, touche } from '../../../test/navigateur/entrees.js';
|
||||
import { monterApp } from '../../../test/navigateur/montage.js';
|
||||
import { composantes, sousTheme } from '../../../test/navigateur/rendu.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import Reglages from './Reglages.svelte';
|
||||
import { LISTE_PAR_DEFAUT, admissionSous, etatAffiche, etatsDesCriteres, gestesOfferts, listeApres } from './priorites.js';
|
||||
import { REGLAGES_GENERATION, reglagesDeSection } from './reglages.js';
|
||||
|
||||
// Les entrées que l'écran de la génération montre : celles de sa section.
|
||||
|
|
@ -100,6 +113,18 @@ async function monter({ avecCharge = (charge) => charge, props = {} } = {}) {
|
|||
// Les désignations des champs montrés, dans l'ordre du document.
|
||||
const designations = (cible) => [...cible.querySelectorAll('input[data-champ]')].map((entree) => entree.dataset.champ);
|
||||
|
||||
// Les désignations qu'une définition montre, dans l'ordre du document : les
|
||||
// champs, puis chaque bascule suivie des boutons radio des choix qui se
|
||||
// montrent sous elle ; l'ordre des priorités, à son défaut, n'a aucun champ.
|
||||
function designationsAttendues(definition) {
|
||||
const generation = reglagesDeSection(definition, 'generation');
|
||||
const sous = (cle) => generation.filter((e) => e.genre === 'choix' && e.sous === cle).flatMap((e) => e.valeurs.map(() => e.cle));
|
||||
return [
|
||||
...generation.filter(({ genre }) => genre === 'champ').map(({ cle }) => cle),
|
||||
...generation.filter(({ genre }) => genre === 'bascule').flatMap(({ cle }) => [cle, ...sous(cle)]),
|
||||
];
|
||||
}
|
||||
|
||||
// L'entrée désignée par sa clé, et le texte de son libellé.
|
||||
const entree = (cible, cle) => cible.querySelector(`input[data-champ="${cle}"]`);
|
||||
const libelleDe = (cible, cle) => cible.querySelector(`label[for="${entree(cible, cle).id}"]`).textContent.trim();
|
||||
|
|
@ -109,9 +134,26 @@ describe('Reglages : l’écran montre la définition (§ 19.8)', () => {
|
|||
const cible = await monter();
|
||||
expect(DE_LA_GENERATION.length).toBeGreaterThan(0);
|
||||
expect(DE_LA_GENERATION.length).toBeLessThan(REGLAGES_GENERATION.length);
|
||||
expect(designations(cible)).toEqual(['graine', ...DE_LA_GENERATION.map(({ cle }) => cle), 'coeursAutomatiques']);
|
||||
expect(designations(cible)).toEqual(['graine', ...designationsAttendues(REGLAGES_GENERATION), 'coeursAutomatiques']);
|
||||
expect(cible.querySelector('[data-champ="cibleRedondance"]')).toBeNull();
|
||||
for (const { cle, etiquette, genre, defaut } of DE_LA_GENERATION) {
|
||||
for (const { cle, etiquette, genre, defaut, valeurs } of DE_LA_GENERATION) {
|
||||
if (genre === 'choix') {
|
||||
const groupe = cible.querySelector('[data-mode-objectifs]');
|
||||
expect(groupe.querySelector('legend').textContent.trim(), cle).toBe(t(etiquette));
|
||||
const radios = [...groupe.querySelectorAll(`input[type="radio"][data-champ="${cle}"]`)];
|
||||
expect(radios.map((r) => r.value)).toEqual([...valeurs]);
|
||||
for (const radio of radios) {
|
||||
expect(cible.querySelector(`label[for="${radio.id}"]`).textContent.trim()).toBe(t(`${etiquette}.${radio.value}`));
|
||||
expect(radio.checked, radio.value).toBe(radio.value === defaut);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (genre === 'ordre') {
|
||||
const liste = cible.querySelector('[data-priorites]');
|
||||
expect(liste.closest('fieldset').querySelector('legend').textContent.trim()).toBe(t(etiquette));
|
||||
expect(liste.querySelectorAll('[data-palier]')).toHaveLength(defaut.length);
|
||||
continue;
|
||||
}
|
||||
expect(libelleDe(cible, cle), cle).toBe(t(etiquette));
|
||||
// Une charge neuve porte les défauts de la définition.
|
||||
if (genre === 'bascule') expect(entree(cible, cle).checked, cle).toBe(defaut);
|
||||
|
|
@ -148,8 +190,8 @@ describe('Reglages : l’écran montre la définition (§ 19.8)', () => {
|
|||
}),
|
||||
props: { definition },
|
||||
});
|
||||
const cles = DE_LA_GENERATION.map(({ cle: c }) => c);
|
||||
expect(designations(cible)).toEqual(['graine', cles[0], 'generation.epreuve', ...cles.slice(1), 'epreuveBascule', 'coeursAutomatiques']);
|
||||
const [premier, ...autres] = designationsAttendues(REGLAGES_GENERATION);
|
||||
expect(designations(cible)).toEqual(['graine', premier, 'generation.epreuve', ...autres, 'epreuveBascule', 'coeursAutomatiques']);
|
||||
expect(libelleDe(cible, 'generation.epreuve')).toBe(t('generation.coeurs.titre'));
|
||||
expect(entree(cible, 'generation.epreuve').value).toBe('4321');
|
||||
expect(libelleDe(cible, 'epreuveBascule')).toBe(t('generation.contraintes.titre'));
|
||||
|
|
@ -179,8 +221,342 @@ describe('l’habillage des réglages', () => {
|
|||
}
|
||||
});
|
||||
}
|
||||
const legendes = [...cible.querySelectorAll('legend')];
|
||||
expect(legendes.length).toBe(2);
|
||||
// Les contraintes, l'ordre des priorités et les cœurs : trois groupes ;
|
||||
// le choix du mode, sous sa case, se lit comme le libellé d'un champ.
|
||||
const legendes = [...cible.querySelectorAll('fieldset:not([data-mode-objectifs]) > legend')];
|
||||
expect(legendes.length).toBe(3);
|
||||
for (const legende of legendes) expect(getComputedStyle(legende).textTransform).toBe('uppercase');
|
||||
expect(getComputedStyle(cible.querySelector('[data-mode-objectifs] > legend')).textTransform).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
// --- L'éditeur des priorités et le choix du mode, dans l'application ----------
|
||||
|
||||
const [ECART, EXCEDENT, CUMULEES, HORS_OBJECTIF, REPETEES, ANIMATEURS, REDONDANCE] = CRITERES;
|
||||
|
||||
// Les montages de l'application, démontés après l'épreuve ; le collecteur
|
||||
// d'erreurs n'a rien reçu pendant elle.
|
||||
const applications = [];
|
||||
afterEach(() => {
|
||||
const erreurs = applications.flatMap((m) => m.erreurs());
|
||||
for (const m of applications.splice(0).reverse()) m.demonter();
|
||||
window.scrollTo(0, 0);
|
||||
expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]);
|
||||
});
|
||||
|
||||
// L'application sur la petite démonstration, en écriture sauf ecriture
|
||||
// faux, la section de la génération ouverte.
|
||||
async function demonstration({ ecriture = true } = {}) {
|
||||
await fixerCadre(1366, 700);
|
||||
const m = await monterApp({ executeur: creerExecuteurDirect() });
|
||||
applications.push(m);
|
||||
await m.facade.chargerDemonstration('petite');
|
||||
if (ecriture) await m.seance.passerEnEcriture();
|
||||
m.ui.allerA('generation');
|
||||
flushSync();
|
||||
return m;
|
||||
}
|
||||
|
||||
const section = (m) => m.racine.querySelector('[data-section="generation"]');
|
||||
const liste = (m) => section(m).querySelector('[data-priorites]');
|
||||
const editeur = (m) => liste(m).closest('fieldset');
|
||||
const critereDe = (m, nom) => liste(m).querySelector(`[data-priorite="${nom}"]`);
|
||||
const bouton = (m, nom, geste) => critereDe(m, nom)?.querySelector(`[data-commande="${geste}"]`) ?? null;
|
||||
const offerts = (m, nom) => [...critereDe(m, nom).querySelectorAll('button[data-commande]')].map((b) => b.dataset.commande);
|
||||
const annonce = (m) => editeur(m).querySelector(':scope > [role="status"]').textContent.trim();
|
||||
const groupe = (m) => section(m).querySelector('[data-mode-objectifs]');
|
||||
const radio = (m, valeur) => groupe(m).querySelector(`input[type="radio"][value="${valeur}"]`);
|
||||
const charge = (m) => m.seance.etat().charge;
|
||||
const revision = (m) => m.seance.etat().evenement.revision;
|
||||
const journal = (m) => {
|
||||
const { base } = m.seance.etat().evenement;
|
||||
return lireJournal(m.fs.contenu('documents', `${base}${SUFFIXES.journal}`)).entrees.length;
|
||||
};
|
||||
const ordreDe = (m) => charge(m).reglages.priorites ?? structuredClone(LISTE_PAR_DEFAUT);
|
||||
|
||||
// Laisse passer une image, la file de la séance passée, puis le rendu.
|
||||
async function passer(m) {
|
||||
await m.seance.formeNommee(null).catch(() => undefined);
|
||||
await new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||||
flushSync();
|
||||
}
|
||||
|
||||
// Un clic réel au centre de l'élément, puis la file de la séance passée.
|
||||
async function cliquer(m, element) {
|
||||
expect(element, 'élément à cliquer').not.toBeNull();
|
||||
element.scrollIntoView({ block: 'center' });
|
||||
const { left, top, width, height } = element.getBoundingClientRect();
|
||||
await pointer('presser', left + width / 2, top + height / 2);
|
||||
await pointer('relacher', left + width / 2, top + height / 2);
|
||||
await passer(m);
|
||||
}
|
||||
|
||||
// Une touche sur l'élément qui a le focus, puis la file passée.
|
||||
async function presser(m, cle) {
|
||||
await touche(cle);
|
||||
await passer(m);
|
||||
}
|
||||
|
||||
describe('l’ordre des priorités : l’éditeur (§ 5.7, A11, A12)', () => {
|
||||
test('au défaut : une liste ordonnée de sept paliers numérotés en texte, chaque critère sous son nom complet, son état lu dans la table d’évaluation, aucun poids', async () => {
|
||||
const m = await demonstration();
|
||||
expect(liste(m).tagName).toBe('OL');
|
||||
const paliers = [...liste(m).querySelectorAll(':scope > li[data-palier]')];
|
||||
expect(paliers).toHaveLength(7);
|
||||
paliers.forEach((palier, rang) => {
|
||||
expect(palier.dataset.palier).toBe(String(rang));
|
||||
expect(palier.textContent).toContain(t('generation.priorites.palier', { palier: rang + 1, paliers: 7 }));
|
||||
});
|
||||
const etats = etatsDesCriteres(charge(m));
|
||||
for (const nom of CRITERES) {
|
||||
expect(critereDe(m, nom).textContent, nom).toContain(t(`mobilier.critere.${nom}`));
|
||||
const etat = etatAffiche(etats.get(nom));
|
||||
for (const ecrit of ['decoche', 'sansObjet']) {
|
||||
expect(critereDe(m, nom).textContent.includes(t(`generation.priorites.etat.${ecrit}`)), `${nom} ${ecrit}`).toBe(etat === ecrit);
|
||||
}
|
||||
}
|
||||
// La petite démonstration ne porte aucun objectif, et sa case des
|
||||
// animateurs est décochée.
|
||||
expect(etatAffiche(etats.get(HORS_OBJECTIF))).toBe('sansObjet');
|
||||
expect(etatAffiche(etats.get(ANIMATEURS))).toBe('decoche');
|
||||
expect(editeur(m).querySelector('input[data-champ^="poids."]')).toBeNull();
|
||||
expect(editeur(m).textContent).toContain(t('generation.priorites.parDefaut'));
|
||||
expect(annonce(m)).toBe('');
|
||||
});
|
||||
|
||||
test('rouge : les gestes offerts sont ceux du modèle sous l’admission de la commande ; ni « joindre » ni « séparer » pour l’écart et les rencontres répétées, ni « joindre » pour le critère qui les suit', async () => {
|
||||
const m = await demonstration();
|
||||
const admet = admissionSous(charge(m).reglages);
|
||||
for (const nom of CRITERES) expect(offerts(m, nom), nom).toEqual(gestesOfferts(ordreDe(m), nom, admet));
|
||||
for (const nom of [ECART, EXCEDENT, REPETEES, ANIMATEURS]) expect(offerts(m, nom), nom).not.toContain('joindrePalier');
|
||||
for (const nom of CRITERES) expect(offerts(m, nom), nom).not.toContain('separerPalier');
|
||||
expect(offerts(m, ECART)).toEqual(['descendreCritere']);
|
||||
expect(offerts(m, REDONDANCE)).toEqual(['monterCritere', 'joindrePalier']);
|
||||
// Aucun bouton ne nomme une touche.
|
||||
for (const b of editeur(m).querySelectorAll('button')) expect(b.textContent).not.toMatch(/Alt|Ctrl|Maj|↑|↓|Entrée/);
|
||||
});
|
||||
|
||||
test('chaque bouton de l’éditeur a un nom accessible à lui, qui nomme le critère qu’il déplace, au défaut comme dans un palier partagé', async () => {
|
||||
const m = await demonstration();
|
||||
// Le nom accessible d'un bouton sans aria-label ni aria-labelledby est
|
||||
// son texte.
|
||||
const noms = () =>
|
||||
[...editeur(m).querySelectorAll('button')].map((b) => {
|
||||
expect(b.hasAttribute('aria-label') || b.hasAttribute('aria-labelledby')).toBe(false);
|
||||
return b.textContent.trim();
|
||||
});
|
||||
const verifier = () => {
|
||||
const tous = noms();
|
||||
expect(new Set(tous).size, tous.join(' | ')).toBe(tous.length);
|
||||
for (const nom of CRITERES) {
|
||||
for (const b of critereDe(m, nom).querySelectorAll('button[data-commande]')) {
|
||||
expect(b.textContent, `${nom} ${b.dataset.commande}`).toContain(t(`mobilier.critere.${nom}`));
|
||||
}
|
||||
}
|
||||
};
|
||||
verifier();
|
||||
await cliquer(m, bouton(m, REDONDANCE, 'joindrePalier'));
|
||||
expect(bouton(m, ANIMATEURS, 'monterCritere')).not.toBeNull();
|
||||
verifier();
|
||||
});
|
||||
|
||||
test('un geste au pointeur : une commande, une entrée ; l’annonce dit le palier ; le focus suit le critère déplacé ; un poids par critère du palier partagé', async () => {
|
||||
const m = await demonstration();
|
||||
const [r0, e0] = [revision(m), journal(m)];
|
||||
const attendue = listeApres(LISTE_PAR_DEFAUT, 'joindrePalier', REDONDANCE);
|
||||
await cliquer(m, bouton(m, REDONDANCE, 'joindrePalier'));
|
||||
expect(revision(m)).toBe(r0 + 1);
|
||||
expect(journal(m)).toBe(e0 + 1);
|
||||
expect(charge(m).reglages.priorites).toEqual(attendue);
|
||||
expect(liste(m).querySelectorAll(':scope > li[data-palier]')).toHaveLength(6);
|
||||
expect(annonce(m)).toBe(t('generation.priorites.annonce', { critere: REDONDANCE, palier: 6, paliers: 6, avec: [ANIMATEURS] }));
|
||||
expect(critereDe(m, REDONDANCE).contains(document.activeElement)).toBe(true);
|
||||
expect(editeur(m).textContent).toContain(t('generation.priorites.regle'));
|
||||
const poids = [...editeur(m).querySelectorAll('input[data-champ^="poids."]')].map((e) => e.dataset.champ);
|
||||
expect(poids).toEqual([`poids.${ANIMATEURS}`, `poids.${REDONDANCE}`]);
|
||||
expect(offerts(m, REDONDANCE)).toEqual(['monterCritere', 'separerPalier']);
|
||||
});
|
||||
|
||||
test('au clavier : Entrée sur « Monter », Espace sur « Descendre » ; chaque geste une entrée ; le focus reste sur le bouton du critère déplacé', async () => {
|
||||
const m = await demonstration();
|
||||
const e0 = journal(m);
|
||||
bouton(m, CUMULEES, 'monterCritere').focus();
|
||||
await presser(m, 'Enter');
|
||||
expect(charge(m).reglages.priorites).toEqual(listeApres(LISTE_PAR_DEFAUT, 'monterCritere', CUMULEES));
|
||||
expect(document.activeElement).toBe(bouton(m, CUMULEES, 'monterCritere'));
|
||||
bouton(m, CUMULEES, 'descendreCritere').focus();
|
||||
await presser(m, ' ');
|
||||
expect(charge(m).reglages.priorites).toBeUndefined();
|
||||
expect(journal(m)).toBe(e0 + 2);
|
||||
expect(document.activeElement).toBe(bouton(m, CUMULEES, 'descendreCritere'));
|
||||
expect(annonce(m)).toBe(t('generation.priorites.annonce', { critere: CUMULEES, palier: 3, paliers: 7, avec: [] }));
|
||||
});
|
||||
|
||||
test('le poids d’un critère d’un palier partagé : 0 refusé contre son champ, rien d’écrit ; 5 écrit, une entrée', async () => {
|
||||
const m = await demonstration();
|
||||
await cliquer(m, bouton(m, REDONDANCE, 'joindrePalier'));
|
||||
const champ = () => editeur(m).querySelector(`input[data-champ="poids.${REDONDANCE}"]`);
|
||||
expect(editeur(m).querySelector(`label[for="${champ().id}"]`).textContent.trim()).toBe(t('generation.priorites.poids', { critere: REDONDANCE }));
|
||||
const e0 = journal(m);
|
||||
await cliquer(m, champ());
|
||||
champ().select();
|
||||
await touche('0');
|
||||
await presser(m, 'Tab');
|
||||
expect(champ().parentElement.querySelector('[data-refus="POIDS_HORS_DOMAINE"]')).not.toBeNull();
|
||||
expect(journal(m)).toBe(e0);
|
||||
await cliquer(m, champ());
|
||||
champ().select();
|
||||
await touche('5');
|
||||
await presser(m, 'Enter');
|
||||
expect(charge(m).reglages.priorites.at(-1)).toEqual([
|
||||
{ critere: ANIMATEURS, poids: 1 },
|
||||
{ critere: REDONDANCE, poids: 5 },
|
||||
]);
|
||||
expect(journal(m)).toBe(e0 + 1);
|
||||
});
|
||||
|
||||
test('« Rétablir l’ordre par défaut » : sans fenêtre, une entrée, le réglage retiré, annoncé', async () => {
|
||||
const m = await demonstration();
|
||||
await cliquer(m, bouton(m, REDONDANCE, 'joindrePalier'));
|
||||
const e0 = journal(m);
|
||||
await cliquer(m, editeur(m).querySelector('[data-commande="retablirPriorites"]'));
|
||||
expect(document.querySelector('dialog[open]')).toBeNull();
|
||||
expect(Object.hasOwn(charge(m).reglages, 'priorites')).toBe(false);
|
||||
expect(journal(m)).toBe(e0 + 1);
|
||||
expect(liste(m).querySelectorAll(':scope > li[data-palier]')).toHaveLength(7);
|
||||
expect(annonce(m)).toBe(t('generation.priorites.annonceRetablie'));
|
||||
});
|
||||
|
||||
test('en lecture et sur un plan bloqué : le geste refusé contre la liste, avec son remède ; rien d’écrit', async () => {
|
||||
const m = await demonstration({ ecriture: false });
|
||||
const ecritures = m.trace.length;
|
||||
await cliquer(m, bouton(m, REDONDANCE, 'joindrePalier'));
|
||||
const lecture = editeur(m).querySelector(':scope > [data-refus="MODE_LECTURE"]');
|
||||
expect(lecture).not.toBeNull();
|
||||
// Le groupe de l'éditeur est décrit par le refus ; le focus reste sur
|
||||
// le bouton du geste.
|
||||
expect(editeur(m).getAttribute('aria-describedby')).toBe(lecture.id);
|
||||
expect(document.activeElement).toBe(bouton(m, REDONDANCE, 'joindrePalier'));
|
||||
expect(lecture.querySelector('[data-commande="modifier"]')).not.toBeNull();
|
||||
expect(Object.hasOwn(charge(m).reglages, 'priorites')).toBe(false);
|
||||
expect(m.trace.length).toBe(ecritures);
|
||||
|
||||
await m.seance.passerEnEcriture();
|
||||
await m.seance.executer('changerEtat', { etat: 'bloque' });
|
||||
flushSync();
|
||||
const ecrituresBloque = m.trace.length;
|
||||
await cliquer(m, bouton(m, CUMULEES, 'monterCritere'));
|
||||
expect(editeur(m).querySelector(':scope > [data-refus="LECTURE_SEULE"] [data-commande="debloquer"]')).not.toBeNull();
|
||||
expect(Object.hasOwn(charge(m).reglages, 'priorites')).toBe(false);
|
||||
expect(m.trace.length).toBe(ecrituresBloque);
|
||||
});
|
||||
});
|
||||
|
||||
describe('le mode des objectifs (§ 5.3, A8, A12, D22, D23)', () => {
|
||||
test('sous la case « Respecter les objectifs des tables » : sa légende, trois boutons radio expliqués, le défaut choisi, l’état du critère écrit à côté', async () => {
|
||||
const m = await demonstration();
|
||||
const caseObjectifs = section(m).querySelector('input[type="checkbox"][data-champ="respecterObjectifs"]');
|
||||
const caseAnimateurs = section(m).querySelector('input[type="checkbox"][data-champ="rencontrerAnimateurs"]');
|
||||
expect(caseObjectifs.compareDocumentPosition(groupe(m)) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(groupe(m).compareDocumentPosition(caseAnimateurs) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(groupe(m).tagName).toBe('FIELDSET');
|
||||
expect(groupe(m).querySelector('legend').textContent.trim()).toBe(t('generation.reglage.modeObjectifs'));
|
||||
for (const valeur of MODES_OBJECTIFS) {
|
||||
const r = radio(m, valeur);
|
||||
expect(groupe(m).querySelector(`label[for="${r.id}"]`).textContent.trim()).toBe(t(`generation.reglage.modeObjectifs.${valeur}`));
|
||||
expect(document.getElementById(r.getAttribute('aria-describedby')).textContent.trim()).toBe(
|
||||
t(`generation.reglage.modeObjectifs.${valeur}.explication`),
|
||||
);
|
||||
expect(r.checked, valeur).toBe(valeur === 'places');
|
||||
}
|
||||
expect(groupe(m).textContent).toContain(t('generation.reglage.modeObjectifs.etat.sansObjet'));
|
||||
});
|
||||
|
||||
test('un choix au pointeur, une entrée ; au clavier, la flèche choisit le voisin ; l’état décoché s’écrit sans recalcul', async () => {
|
||||
const m = await demonstration();
|
||||
const e0 = journal(m);
|
||||
await cliquer(m, radio(m, 'chacun'));
|
||||
expect(charge(m).reglages.modeObjectifs).toBe('chacun');
|
||||
expect(radio(m, 'chacun').checked).toBe(true);
|
||||
expect(journal(m)).toBe(e0 + 1);
|
||||
radio(m, 'chacun').focus();
|
||||
await presser(m, 'ArrowDown');
|
||||
expect(charge(m).reglages.modeObjectifs).toBe('minimum');
|
||||
expect(radio(m, 'minimum').checked).toBe(true);
|
||||
expect(journal(m)).toBe(e0 + 2);
|
||||
await cliquer(m, section(m).querySelector('input[type="checkbox"][data-champ="respecterObjectifs"]'));
|
||||
expect(groupe(m).textContent).toContain(t('generation.reglage.modeObjectifs.etat.decoche'));
|
||||
// Toujours modifiable, case décochée.
|
||||
await cliquer(m, radio(m, 'places'));
|
||||
expect(Object.hasOwn(charge(m).reglages, 'modeObjectifs')).toBe(false);
|
||||
});
|
||||
|
||||
test('rouge : sous « chacun », pas de « joindre » aux tours hors objectif ; joints, « l’équité d’abord » refusé contre le groupe, le choix revenu', async () => {
|
||||
const m = await demonstration();
|
||||
await cliquer(m, radio(m, 'chacun'));
|
||||
expect(offerts(m, HORS_OBJECTIF)).not.toContain('joindrePalier');
|
||||
await cliquer(m, radio(m, 'places'));
|
||||
expect(offerts(m, HORS_OBJECTIF)).toContain('joindrePalier');
|
||||
await cliquer(m, bouton(m, HORS_OBJECTIF, 'joindrePalier'));
|
||||
const e0 = journal(m);
|
||||
await cliquer(m, radio(m, 'minimum'));
|
||||
const refus = groupe(m).querySelector('[data-refus="OBJECTIFS_PALIER_PARTAGE"]');
|
||||
expect(refus).not.toBeNull();
|
||||
expect(refus.querySelector(':scope > p').textContent).toBe(t('OBJECTIFS_PALIER_PARTAGE', { mode: 'minimum', palier: 2 }));
|
||||
// Chaque bouton radio est décrit par le refus, en plus de son
|
||||
// explication ; le focus reste dans le groupe.
|
||||
expect(refus.id).not.toBe('');
|
||||
for (const valeur of MODES_OBJECTIFS) {
|
||||
expect(radio(m, valeur).getAttribute('aria-describedby').split(' '), valeur).toContain(refus.id);
|
||||
}
|
||||
expect(groupe(m).contains(document.activeElement)).toBe(true);
|
||||
expect(radio(m, 'places').checked).toBe(true);
|
||||
expect(radio(m, 'minimum').checked).toBe(false);
|
||||
expect(Object.hasOwn(charge(m).reglages, 'modeObjectifs')).toBe(false);
|
||||
expect(journal(m)).toBe(e0);
|
||||
});
|
||||
|
||||
test('en lecture : le choix refusé contre le groupe, le mode inchangé, le choix revenu', async () => {
|
||||
const m = await demonstration({ ecriture: false });
|
||||
await cliquer(m, radio(m, 'chacun'));
|
||||
expect(groupe(m).querySelector('[data-refus="MODE_LECTURE"]')).not.toBeNull();
|
||||
expect(radio(m, 'places').checked).toBe(true);
|
||||
expect(radio(m, 'chacun').checked).toBe(false);
|
||||
expect(Object.hasOwn(charge(m).reglages, 'modeObjectifs')).toBe(false);
|
||||
});
|
||||
|
||||
test('sur un plan bloqué : le choix refusé contre le groupe, avec « Débloquer » ; rien d’écrit, le choix revenu', async () => {
|
||||
const m = await demonstration();
|
||||
await m.seance.executer('changerEtat', { etat: 'bloque' });
|
||||
flushSync();
|
||||
const ecritures = m.trace.length;
|
||||
await cliquer(m, radio(m, 'chacun'));
|
||||
expect(groupe(m).querySelector('[data-refus="LECTURE_SEULE"] [data-commande="debloquer"]')).not.toBeNull();
|
||||
expect(radio(m, 'places').checked).toBe(true);
|
||||
expect(radio(m, 'chacun').checked).toBe(false);
|
||||
expect(m.trace.length).toBe(ecritures);
|
||||
expect(Object.hasOwn(charge(m).reglages, 'modeObjectifs')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('le contraste de l’éditeur et du choix du mode (§ 14.5)', () => {
|
||||
test('chaque texte, poids et refus compris, à 4,5:1 au moins dans les deux thèmes, mesuré au rendu', async () => {
|
||||
const m = await demonstration();
|
||||
// Un palier partagé, ses poids, et un refus contre le groupe du mode.
|
||||
await cliquer(m, bouton(m, HORS_OBJECTIF, 'joindrePalier'));
|
||||
await cliquer(m, radio(m, 'minimum'));
|
||||
expect(groupe(m).querySelector('[data-refus]')).not.toBeNull();
|
||||
expect(editeur(m).querySelectorAll('input[data-champ^="poids."]')).toHaveLength(2);
|
||||
// Une faute est un texte sous 4,5:1 ou sans fond opaque dessous : la
|
||||
// liste vide porte le seuil.
|
||||
for (const theme of ['clair', 'sombre']) {
|
||||
await sousTheme(theme, async () => {
|
||||
for (const [nom, racine] of [['éditeur', editeur(m)], ['mode', groupe(m)]]) {
|
||||
const { fautes, mesures } = releverTextes(racine);
|
||||
expect(mesures, `${theme} ${nom}`).toBeGreaterThan(3);
|
||||
expect(fautes, `${theme} ${nom}`).toEqual([]);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
// « Générer ». Son refus, refusGraine, se pose contre elle ; une frappe
|
||||
// appelle saisirGraine, qui le retire.
|
||||
//
|
||||
// Les quatre contraintes et l'attribution des sièges (§ 5.3) sont des
|
||||
// Les six contraintes et l'attribution des sièges (§ 5.3) sont des
|
||||
// cases à cocher, chacune sous son nom du spec : un clic, ou la barre
|
||||
// d'espace, exécute modifierReglage {cle, valeur} — une entrée par
|
||||
// bascule. À l'issue du geste, la case montre la valeur de la charge,
|
||||
|
|
@ -35,6 +35,22 @@
|
|||
// bloqué, support rompu — se pose à côté de la case, avec son remède, et
|
||||
// rien ne s'écrit.
|
||||
//
|
||||
// Un choix (§ 5.3, A8) se montre sous sa bascule (entree.sous) : un groupe
|
||||
// data-mode-objectifs, sa légende, un bouton radio par valeur, chacun sous
|
||||
// son libellé et décrit par son explication (aria-describedby), puis par
|
||||
// le refus du groupe quand il y en a un. Il reste modifiable quel que soit
|
||||
// l'état de son critère, que le groupe écrit en texte, « décoché » ou
|
||||
// « sans objet », tel que la table d'évaluation le porte
|
||||
// (etatsDesCriteres) : rien ne se recalcule ici. Choisir, au pointeur
|
||||
// ou aux flèches du clavier, exécute modifierReglage {cle, valeur} — une
|
||||
// entrée par choix. Un refus — un mode qui compare deux valeurs alors que
|
||||
// son critère partage un palier (OBJECTIFS_PALIER_PARTAGE), la lecture, un
|
||||
// plan bloqué — se pose contre le groupe, et le choix revient à la valeur
|
||||
// de la charge.
|
||||
//
|
||||
// L'ordre des priorités (genre 'ordre') suit les contraintes, dans son
|
||||
// éditeur (Priorites.svelte), qui lit les mêmes états.
|
||||
//
|
||||
// Les cœurs de calcul sont un réglage du poste, que le contrôleur de
|
||||
// génération tient pour la séance du logiciel : ni le fichier ni le journal
|
||||
// ne les portent, et ils se règlent en lecture comme en écriture. La case
|
||||
|
|
@ -54,6 +70,8 @@
|
|||
import Champ from '../primitives/Champ.svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import { effetDe } from '../raccourcis.js';
|
||||
import Priorites from './Priorites.svelte';
|
||||
import { etatAffiche, etatsDesCriteres } from './priorites.js';
|
||||
import { REGLAGES_GENERATION, reglagesDeSection, reglagesDuGenre, valeurDuReglage } from './reglages.js';
|
||||
|
||||
let {
|
||||
|
|
@ -111,6 +129,14 @@
|
|||
const deLaGeneration = $derived(reglagesDeSection(definition, 'generation'));
|
||||
const champs = $derived(reglagesDuGenre(deLaGeneration, 'champ'));
|
||||
const bascules = $derived(reglagesDuGenre(deLaGeneration, 'bascule'));
|
||||
const choix = $derived(reglagesDuGenre(deLaGeneration, 'choix'));
|
||||
const ordres = $derived(reglagesDuGenre(deLaGeneration, 'ordre'));
|
||||
// Les choix qui se montrent sous la bascule de clé cle.
|
||||
const choixSous = (cle) => choix.filter((entree) => entree.sous === cle);
|
||||
|
||||
// L'état de chaque critère, lu dans la table d'évaluation de la charge ;
|
||||
// null quand la configuration ne se normalise pas.
|
||||
const etats = $derived(choix.length + ordres.length === 0 ? null : etatsDesCriteres(charge));
|
||||
|
||||
// La lecture d'une saisie du réglage nom : la validation du contrôleur sur
|
||||
// les réglages proposés, ce réglage remplacé par la saisie ; son refus, ou
|
||||
|
|
@ -144,6 +170,28 @@
|
|||
caseACocher.checked = valeurDuReglage(charge.reglages, entree);
|
||||
}
|
||||
}
|
||||
|
||||
// Le refus du dernier choix, { cle, refus }, posé contre son groupe.
|
||||
let refusChoix = $state.raw(null);
|
||||
|
||||
// Le choix d'un bouton radio : la séance écrit sa valeur ; à l'issue,
|
||||
// chaque bouton du groupe montre la valeur de la charge, écrite ou
|
||||
// inchangée — un choix refusé revient ainsi à l'ancien.
|
||||
async function choisir(evenement, entree) {
|
||||
const groupe = evenement.currentTarget.closest('fieldset');
|
||||
const { value: valeur } = evenement.currentTarget;
|
||||
const { cle } = entree;
|
||||
refusChoix = null;
|
||||
try {
|
||||
await seance.executer('modifierReglage', { cle, valeur });
|
||||
} catch (erreur) {
|
||||
if (typeof erreur?.code !== 'string') throw erreur;
|
||||
refusChoix = { cle, refus: { code: erreur.code, details: erreur.details ?? {} } };
|
||||
} finally {
|
||||
const courante = valeurDuReglage(charge.reglages, entree);
|
||||
for (const bouton of groupe.querySelectorAll(`input[type="radio"][data-champ="${cle}"]`)) bouton.checked = bouton.value === courante;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="reglages" data-reglages>
|
||||
|
|
@ -190,8 +238,43 @@
|
|||
<Refus refus={refusBascule.refus} lever={() => (refusBascule = null)} />
|
||||
{/if}
|
||||
</div>
|
||||
{#each choixSous(cle) as entreeChoix (entreeChoix.cle)}
|
||||
{@const etat = etatAffiche(etats?.get(entreeChoix.critere))}
|
||||
{@const courante = valeurDuReglage(charge.reglages, entreeChoix)}
|
||||
{@const idRefusChoix = `${uid}-${entreeChoix.cle}-refus`}
|
||||
{@const refuse = refusChoix !== null && refusChoix.cle === entreeChoix.cle}
|
||||
<fieldset class="choix" data-mode-objectifs>
|
||||
<legend>{t(entreeChoix.etiquette)}</legend>
|
||||
{#each entreeChoix.valeurs as valeur (valeur)}
|
||||
{@const id = `${uid}-${entreeChoix.cle}-${valeur}`}
|
||||
<div class="option">
|
||||
<input
|
||||
{id}
|
||||
type="radio"
|
||||
name={`${uid}-${entreeChoix.cle}`}
|
||||
value={valeur}
|
||||
data-champ={entreeChoix.cle}
|
||||
checked={courante === valeur}
|
||||
aria-describedby={refuse ? `${id}-explication ${idRefusChoix}` : `${id}-explication`}
|
||||
onchange={(evenement) => choisir(evenement, entreeChoix)}
|
||||
/>
|
||||
<label for={id}>{t(`${entreeChoix.etiquette}.${valeur}`)}</label>
|
||||
<p class="explication" id={`${id}-explication`}>{t(`${entreeChoix.etiquette}.${valeur}.explication`)}</p>
|
||||
</div>
|
||||
{/each}
|
||||
{#if etat !== null}
|
||||
<p class="note">{t(`${entreeChoix.etiquette}.etat.${etat}`)}</p>
|
||||
{/if}
|
||||
{#if refuse}
|
||||
<Refus id={idRefusChoix} refus={refusChoix.refus} lever={() => (refusChoix = null)} />
|
||||
{/if}
|
||||
</fieldset>
|
||||
{/each}
|
||||
{/each}
|
||||
</fieldset>
|
||||
{#each ordres as entree (entree.cle)}
|
||||
<Priorites {charge} {entree} {etats} />
|
||||
{/each}
|
||||
<fieldset class="coeurs" data-coeurs>
|
||||
<legend>{t('generation.coeurs.titre')}</legend>
|
||||
<div class="bascule">
|
||||
|
|
@ -319,6 +402,52 @@
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
/* Le choix, sous sa case, sur toute la largeur du groupe : sa légende se
|
||||
lit comme le libellé d'un champ, chaque bouton suivi de son explication
|
||||
en encre secondaire. */
|
||||
.choix {
|
||||
display: flex;
|
||||
flex-basis: 100%;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
margin: 0 0 0 var(--espace-5);
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.choix > legend {
|
||||
padding: 0;
|
||||
margin-bottom: var(--espace-2);
|
||||
color: var(--couleur-page-texte);
|
||||
font-size: var(--taille-texte);
|
||||
font-weight: normal;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.option {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 0 var(--espace-1);
|
||||
}
|
||||
|
||||
.option input {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.option input:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.explication {
|
||||
grid-column: 2;
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font-size: var(--taille-texte-petit);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,11 @@
|
|||
// section, une seule, sur l'explication de cette colonne, et le quitter la
|
||||
// referme ; le terme expliqué porte aria-describedby vers elle.
|
||||
//
|
||||
// Les réglages (Reglages.svelte) portent le choix du mode des objectifs,
|
||||
// sous sa case, et l'éditeur de l'ordre des priorités, après les
|
||||
// contraintes (§ 5.3, § 5.7) : chacun s'écrit par ses commandes, sans que
|
||||
// la section s'en mêle.
|
||||
//
|
||||
// Habillage : la meilleure proposition est une carte à elle, chaque valeur
|
||||
// sous son intitulé ; les réglages, « Générer » — l'action principale de
|
||||
// l'écran —, la question, l'avancement et l'issue vivent dans la carte des
|
||||
|
|
|
|||
256
src/interface/generation/priorites.js
Normal 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;
|
||||
}
|
||||
323
src/interface/generation/priorites.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -7,7 +7,8 @@
|
|||
// de 'generation' —, et le tableau des réglages de la documentation
|
||||
// s'engendre de toutes : un réglage ajouté ici paraît des deux côtés.
|
||||
//
|
||||
// Une entrée est { nom, cle, etiquette, unite, defaut, genre, section } :
|
||||
// Une entrée est { nom, cle, etiquette, unite, defaut, genre, section }, et
|
||||
// une entrée du genre 'choix' porte en plus { valeurs, sous, critere } :
|
||||
// nom son nom dans charge.reglages, ou dans charge.reglages.generation
|
||||
// pour un champ de la génération ;
|
||||
// cle son chemin dans charge.reglages, que la commande qui l'écrit
|
||||
|
|
@ -15,13 +16,24 @@
|
|||
// generation.<nom> pour un champ de la génération, <nom> sinon ;
|
||||
// etiquette la clé du libellé, qui porte l'unité pour un champ ;
|
||||
// unite ce qu'un champ compte, 'propositions' ou 'mouvements' ; null
|
||||
// pour une bascule et pour la cible, une somme sans unité ;
|
||||
// pour une bascule, un choix, l'ordre, et pour la cible, une
|
||||
// somme sans unité ;
|
||||
// defaut la valeur d'une charge neuve, ou celle d'un réglage facultatif
|
||||
// que la charge ne porte pas ;
|
||||
// genre 'champ', une saisie entière, ou 'bascule', une case à cocher ;
|
||||
// genre 'champ', une saisie entière ; 'bascule', une case à cocher ;
|
||||
// 'choix', une valeur parmi valeurs, en boutons radio ; 'ordre',
|
||||
// l'ordre des priorités, une liste de paliers que son éditeur
|
||||
// règle (Priorites.svelte) ;
|
||||
// section la section de l'écran qui le montre : 'generation', écrit par
|
||||
// modifierReglage, ou 'propositions', la cible de la somme de
|
||||
// redondance, écrite par changerCibleRedondance.
|
||||
// modifierReglage — l'ordre par changerPriorites —, ou
|
||||
// 'propositions', la cible de la somme de redondance, écrite par
|
||||
// changerCibleRedondance ;
|
||||
// valeurs les valeurs d'un choix, dans l'ordre de ses boutons ; le
|
||||
// libellé de chacune est <etiquette>.<valeur>, son explication
|
||||
// <etiquette>.<valeur>.explication ;
|
||||
// sous la clé de la bascule sous laquelle le choix se montre ;
|
||||
// critere le critère dont le choix règle la lecture : son état,
|
||||
// « décoché » ou « sans objet », s'écrit à côté du choix.
|
||||
//
|
||||
// La graine, saisie de la commande, et les cœurs, réglage du poste, ne sont
|
||||
// pas au fichier : Reglages.svelte les montre à part.
|
||||
|
|
@ -29,19 +41,32 @@
|
|||
// Les défauts se lisent sur la charge d'un événement neuf, telle que la
|
||||
// commande de création la pose (chargeNeuve) : creerCharge y copie
|
||||
// GENERATION_PAR_DEFAUT et y écrit les bascules, et l'interface n'importe
|
||||
// pas le stockage. La charge lue ici n'est écrite nulle part. La cible,
|
||||
// facultative, n'y est pas : son défaut est CIBLE_REDONDANCE_PAR_DEFAUT.
|
||||
// pas le stockage. La charge lue ici n'est écrite nulle part. Les réglages
|
||||
// facultatifs n'y sont pas : la cible a pour défaut
|
||||
// CIBLE_REDONDANCE_PAR_DEFAUT, l'ordre des priorités LISTE_PAR_DEFAUT, lue
|
||||
// dans le registre par l'application (priorites.js). Le mode prend ses
|
||||
// valeurs et son défaut, le premier, dans MODES_OBJECTIFS, et son critère
|
||||
// dans CASES, celui que sa case fait compter : le registre, que
|
||||
// l'application réexporte (contrainte 35). Les deux cases portent ici le
|
||||
// défaut que le stockage leur lit (§ 8.9), et les épreuves de
|
||||
// reglages.test.js les comparent à ses lecteurs.
|
||||
import { chargeNeuve } from '../../application/commandes.js';
|
||||
import { CIBLE_REDONDANCE_PAR_DEFAUT } from '../../application/evaluation.js';
|
||||
import { CASES, CIBLE_REDONDANCE_PAR_DEFAUT, MODES_OBJECTIFS } from '../../application/evaluation.js';
|
||||
import { SIEGES_PAR_DEFAUT, TOURS_PAR_DEFAUT } from '../../application/evenements.js';
|
||||
import { LISTE_PAR_DEFAUT } from './priorites.js';
|
||||
|
||||
/** @typedef {'champ'|'bascule'} GenreReglage */
|
||||
/** @typedef {'champ'|'bascule'|'choix'|'ordre'} GenreReglage */
|
||||
/** @typedef {'generation'|'propositions'} SectionReglage */
|
||||
/**
|
||||
* @typedef {Readonly<{nom: string, cle: string, etiquette: string, unite: 'propositions'|'mouvements'|null,
|
||||
* defaut: number|boolean, genre: GenreReglage, section: SectionReglage}>} EntreeReglage
|
||||
* defaut: number|boolean|string|ReadonlyArray<ReadonlyArray<{critere: string, poids: number}>>,
|
||||
* genre: GenreReglage, section: SectionReglage,
|
||||
* valeurs?: ReadonlyArray<string>, sous?: string, critere?: string}>} EntreeReglage
|
||||
*/
|
||||
|
||||
// Les genres que valeurDuReglage lit.
|
||||
const GENRES = Object.freeze(['champ', 'bascule', 'choix', 'ordre']);
|
||||
|
||||
const { reglages: DEFAUTS } = chargeNeuve(
|
||||
{ nom: 'reglages', siegesParDefaut: SIEGES_PAR_DEFAUT, tours: TOURS_PAR_DEFAUT },
|
||||
() => 'reglages',
|
||||
|
|
@ -58,17 +83,49 @@ const champ = (nom, unite) =>
|
|||
section: 'generation',
|
||||
});
|
||||
|
||||
const bascule = (nom) =>
|
||||
// Une bascule ; defaut, pour une case facultative que la charge neuve ne
|
||||
// porte pas.
|
||||
const bascule = (nom, defaut = DEFAUTS[nom]) =>
|
||||
Object.freeze({
|
||||
nom,
|
||||
cle: nom,
|
||||
etiquette: `generation.contrainte.${nom}`,
|
||||
unite: null,
|
||||
defaut: DEFAUTS[nom],
|
||||
defaut,
|
||||
genre: 'bascule',
|
||||
section: 'generation',
|
||||
});
|
||||
|
||||
// Le mode des objectifs (§ 5.3, A8, D20) : ce que le critère des tours hors
|
||||
// objectif cherche d'abord. Le défaut, en tête des modes du registre, est le
|
||||
// plus de places servies, que le fichier n'écrit pas. Le choix se montre
|
||||
// sous la case des objectifs, et son critère est celui que cette case fait
|
||||
// compter.
|
||||
const SOUS_LE_MODE = 'respecterObjectifs';
|
||||
const MODE_OBJECTIFS = Object.freeze({
|
||||
nom: 'modeObjectifs',
|
||||
cle: 'modeObjectifs',
|
||||
etiquette: 'generation.reglage.modeObjectifs',
|
||||
unite: null,
|
||||
defaut: MODES_OBJECTIFS[0],
|
||||
genre: 'choix',
|
||||
section: 'generation',
|
||||
valeurs: MODES_OBJECTIFS,
|
||||
sous: SOUS_LE_MODE,
|
||||
critere: Object.keys(CASES).find((nom) => CASES[nom].includes(SOUS_LE_MODE)),
|
||||
});
|
||||
|
||||
// L'ordre des priorités (§ 5.7, A11) : absent du fichier à l'ordre par défaut.
|
||||
const PRIORITES = Object.freeze({
|
||||
nom: 'priorites',
|
||||
cle: 'priorites',
|
||||
etiquette: 'generation.reglage.priorites',
|
||||
unite: null,
|
||||
defaut: LISTE_PAR_DEFAUT,
|
||||
genre: 'ordre',
|
||||
section: 'generation',
|
||||
});
|
||||
|
||||
const CIBLE = Object.freeze({
|
||||
nom: 'cibleRedondance',
|
||||
cle: 'cibleRedondance',
|
||||
|
|
@ -81,9 +138,10 @@ const CIBLE = Object.freeze({
|
|||
|
||||
/**
|
||||
* Les réglages de la charge, dans l'ordre des écrans : les trois champs de
|
||||
* la recherche (§ 5.7, § 5.10), puis les quatre contraintes et l'attribution
|
||||
* des sièges, dans l'ordre du § 5.3 ; enfin la cible de la somme de
|
||||
* redondance (§ 8.9), de la section des propositions.
|
||||
* la recherche (§ 5.7, § 5.10), puis les six contraintes et l'attribution
|
||||
* des sièges, dans l'ordre du § 5.3 ; le mode des objectifs, que l'écran
|
||||
* montre sous sa case ; l'ordre des priorités ; enfin la cible de la somme
|
||||
* de redondance (§ 8.9), de la section des propositions.
|
||||
*
|
||||
* @type {ReadonlyArray<EntreeReglage>}
|
||||
*/
|
||||
|
|
@ -95,7 +153,11 @@ export const REGLAGES_GENERATION = Object.freeze([
|
|||
bascule('nouveauxVoisins'),
|
||||
bascule('nouvelleTable'),
|
||||
bascule('varierAppartenances'),
|
||||
bascule('respecterObjectifs', true),
|
||||
bascule('rencontrerAnimateurs', false),
|
||||
bascule('attribuerSieges'),
|
||||
MODE_OBJECTIFS,
|
||||
PRIORITES,
|
||||
CIBLE,
|
||||
]);
|
||||
|
||||
|
|
@ -124,15 +186,17 @@ export function reglagesDeSection(definition, section) {
|
|||
/**
|
||||
* La valeur qu'une charge porte pour une entrée, lue au chemin de sa clé :
|
||||
* reglages.generation[nom] pour un champ de la génération, reglages[nom]
|
||||
* sinon. Un réglage facultatif absent vaut le défaut de l'entrée.
|
||||
* sinon — une case, le mode, l'ordre des priorités. Un réglage facultatif
|
||||
* absent vaut le défaut de l'entrée. Un ordre se rend tel que la charge le
|
||||
* porte : le stockage le valide à l'écriture comme à la lecture (A16).
|
||||
*
|
||||
* @param {Object} reglages charge.reglages
|
||||
* @param {EntreeReglage} entree
|
||||
* @returns {number|boolean}
|
||||
* @returns {EntreeReglage['defaut']}
|
||||
* @throws {RangeError} pour un genre inconnu
|
||||
*/
|
||||
export function valeurDuReglage(reglages, entree) {
|
||||
if (entree.genre !== 'champ' && entree.genre !== 'bascule') {
|
||||
if (!GENRES.includes(entree.genre)) {
|
||||
throw new RangeError(`genre de réglage inconnu : ${JSON.stringify(entree.genre)}`);
|
||||
}
|
||||
const valeur = entree.cle.split('.').reduce((objet, nom) => objet?.[nom], reglages);
|
||||
|
|
|
|||
|
|
@ -4,15 +4,26 @@
|
|||
// Épreuves de la définition des réglages de la charge (§ 5.3, § 5.7,
|
||||
// § 5.10, § 8.9, § 19.8) : sa forme, figée ; l'ordre des écrans ; les
|
||||
// défauts, ceux d'une charge neuve, GENERATION_PAR_DEFAUT et
|
||||
// CIBLE_REDONDANCE_PAR_DEFAUT compris ; chaque réglage du schéma du fichier,
|
||||
// facultatif compris, et lui seul ; les clés que les commandes acceptent et
|
||||
// les libellés qui existent ; la lecture d'une valeur dans une charge.
|
||||
// CIBLE_REDONDANCE_PAR_DEFAUT compris, et ceux que les lecteurs du stockage
|
||||
// donnent aux réglages facultatifs — les deux cases, le mode des objectifs,
|
||||
// l'ordre des priorités ; chaque réglage du schéma du fichier, facultatif
|
||||
// compris, et lui seul ; les clés que les commandes acceptent et les
|
||||
// libellés qui existent ; la lecture d'une valeur dans une charge.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { appliquerCommande } from '../../application/commandes.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { CIBLE_REDONDANCE_PAR_DEFAUT, GENERATION_PAR_DEFAUT, SCHEMA, creerCharge } from '../../stockage/document.js';
|
||||
import { MODES_OBJECTIFS } from '../../moteur/criteres.js';
|
||||
import {
|
||||
CIBLE_REDONDANCE_PAR_DEFAUT,
|
||||
GENERATION_PAR_DEFAUT,
|
||||
SCHEMA,
|
||||
creerCharge,
|
||||
critereCoche,
|
||||
modeObjectifs,
|
||||
priorites,
|
||||
} from '../../stockage/document.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { REGLAGES_GENERATION, reglagesDeSection, reglagesDuGenre, valeurDuReglage } from './reglages.js';
|
||||
|
||||
|
|
@ -44,30 +55,48 @@ function cheminsDuSchema() {
|
|||
// n'avoir aucune entrée : l'entrée qui s'ajoute l'en retire.
|
||||
const SANS_ENTREE = new Map([
|
||||
['regleObjectifs', 'la règle par défaut des objectifs s’écrit depuis les tables, non depuis la génération'],
|
||||
['respecterObjectifs', 'case des objectifs, sans entrée à l’écran de la génération'],
|
||||
['modeObjectifs', 'mode des objectifs, sans entrée à l’écran de la génération'],
|
||||
['rencontrerAnimateurs', 'case des animateurs, sans entrée à l’écran de la génération'],
|
||||
['priorites', 'ordre des priorités, écrit par son éditeur de la section Génération, non par une entrée'],
|
||||
]);
|
||||
|
||||
// La commande qui écrit un réglage de chaque section, et ses arguments.
|
||||
// La commande qui écrit un réglage de chaque section, et ses arguments :
|
||||
// l'ordre des priorités par changerPriorites, les autres réglages de la
|
||||
// génération par modifierReglage.
|
||||
const ECRITURES = new Map([
|
||||
['generation', (entree, valeur) => ['modifierReglage', { cle: entree.cle, valeur }]],
|
||||
[
|
||||
'generation',
|
||||
(entree, valeur) =>
|
||||
entree.genre === 'ordre' ? ['changerPriorites', { paliers: valeur }] : ['modifierReglage', { cle: entree.cle, valeur }],
|
||||
],
|
||||
['propositions', (entree, valeur) => ['changerCibleRedondance', { cible: String(valeur) }]],
|
||||
]);
|
||||
|
||||
// Une valeur autre que le défaut de l'entrée : la case contraire, un mode
|
||||
// voisin, un entier de plus, l'ordre par défaut aux deux derniers paliers
|
||||
// joints.
|
||||
function autreValeur({ genre, defaut, valeurs }) {
|
||||
if (genre === 'bascule') return !defaut;
|
||||
if (genre === 'choix') return valeurs.find((valeur) => valeur !== defaut);
|
||||
if (genre === 'ordre') return [...defaut.slice(0, -2), [...defaut.at(-2), ...defaut.at(-1)]];
|
||||
return defaut + 1;
|
||||
}
|
||||
|
||||
// Les clés de chaque entrée, et celles qu'une entrée du genre choix porte en
|
||||
// plus : ses valeurs, la bascule sous laquelle elle se montre, le critère
|
||||
// dont elle règle la lecture.
|
||||
const CLES_D_ENTREE = ['nom', 'cle', 'etiquette', 'unite', 'defaut', 'genre', 'section'];
|
||||
const CLES_DU_CHOIX = ['valeurs', 'sous', 'critere'];
|
||||
|
||||
describe('REGLAGES_GENERATION : la définition que lisent les écrans et la documentation (§ 19.8)', () => {
|
||||
test('chaque entrée porte nom, cle, etiquette, unite, defaut, genre et section ; la définition comme chaque entrée sont figées', () => {
|
||||
test('chaque entrée porte nom, cle, etiquette, unite, defaut, genre et section, un choix ses valeurs, sa bascule et son critère ; la définition comme chaque entrée sont figées', () => {
|
||||
assert.ok(REGLAGES_GENERATION.length > 0, 'définition vide');
|
||||
assert.ok(Object.isFrozen(REGLAGES_GENERATION));
|
||||
for (const entree of REGLAGES_GENERATION) {
|
||||
assert.deepEqual(Object.keys(entree), ['nom', 'cle', 'etiquette', 'unite', 'defaut', 'genre', 'section']);
|
||||
assert.deepEqual(Object.keys(entree), entree.genre === 'choix' ? [...CLES_D_ENTREE, ...CLES_DU_CHOIX] : CLES_D_ENTREE);
|
||||
assert.ok(Object.isFrozen(entree), entree.nom);
|
||||
}
|
||||
assert.throws(() => REGLAGES_GENERATION.push({}), TypeError);
|
||||
});
|
||||
|
||||
test('l’ordre des écrans : les trois champs de la recherche, les bascules du § 5.3, puis la cible de la section des propositions', () => {
|
||||
test('l’ordre des écrans : les trois champs de la recherche, les bascules du § 5.3, le mode des objectifs, l’ordre des priorités, puis la cible de la section des propositions', () => {
|
||||
assert.deepEqual(
|
||||
REGLAGES_GENERATION.map(({ nom, genre, section }) => [section, genre, nom]),
|
||||
[
|
||||
|
|
@ -78,7 +107,11 @@ describe('REGLAGES_GENERATION : la définition que lisent les écrans et la docu
|
|||
['generation', 'bascule', 'nouveauxVoisins'],
|
||||
['generation', 'bascule', 'nouvelleTable'],
|
||||
['generation', 'bascule', 'varierAppartenances'],
|
||||
['generation', 'bascule', 'respecterObjectifs'],
|
||||
['generation', 'bascule', 'rencontrerAnimateurs'],
|
||||
['generation', 'bascule', 'attribuerSieges'],
|
||||
['generation', 'choix', 'modeObjectifs'],
|
||||
['generation', 'ordre', 'priorites'],
|
||||
['propositions', 'champ', 'cibleRedondance'],
|
||||
],
|
||||
);
|
||||
|
|
@ -94,13 +127,55 @@ describe('REGLAGES_GENERATION : la définition que lisent les écrans et la docu
|
|||
}
|
||||
assert.deepEqual(champs.map(({ unite }) => unite), ['propositions', 'mouvements', 'mouvements']);
|
||||
const bascules = reglagesDuGenre(generation, 'bascule');
|
||||
assert.equal(bascules.length, 5);
|
||||
assert.equal(bascules.length, 7);
|
||||
for (const { nom, cle, etiquette, unite } of bascules) {
|
||||
assert.equal(cle, nom);
|
||||
assert.equal(etiquette, `generation.contrainte.${nom}`);
|
||||
assert.equal(unite, null);
|
||||
}
|
||||
assert.equal(champs.length + bascules.length, generation.length);
|
||||
const choix = reglagesDuGenre(generation, 'choix');
|
||||
const ordres = reglagesDuGenre(generation, 'ordre');
|
||||
assert.equal(champs.length + bascules.length + choix.length + ordres.length, generation.length);
|
||||
});
|
||||
|
||||
test('le mode des objectifs : un choix à la racine des réglages, ses trois valeurs celles du registre, le plus de places servies par défaut, sous la case des objectifs, réglant les tours hors objectif', () => {
|
||||
const choix = reglagesDuGenre(REGLAGES_GENERATION, 'choix');
|
||||
assert.deepEqual(choix, [
|
||||
{
|
||||
nom: 'modeObjectifs',
|
||||
cle: 'modeObjectifs',
|
||||
etiquette: 'generation.reglage.modeObjectifs',
|
||||
unite: null,
|
||||
defaut: 'places',
|
||||
genre: 'choix',
|
||||
section: 'generation',
|
||||
valeurs: [...MODES_OBJECTIFS],
|
||||
sous: 'respecterObjectifs',
|
||||
critere: 'toursHorsObjectif',
|
||||
},
|
||||
]);
|
||||
assert.ok(Object.isFrozen(choix[0].valeurs));
|
||||
assert.equal(choix[0].defaut, modeObjectifs({ reglages: {} }));
|
||||
assert.ok(reglagesDuGenre(REGLAGES_GENERATION, 'bascule').some(({ cle }) => cle === choix[0].sous));
|
||||
for (const valeur of choix[0].valeurs) {
|
||||
assert.notEqual(t(`${choix[0].etiquette}.${valeur}`), `${choix[0].etiquette}.${valeur}`, valeur);
|
||||
assert.notEqual(t(`${choix[0].etiquette}.${valeur}.explication`), `${choix[0].etiquette}.${valeur}.explication`, valeur);
|
||||
}
|
||||
});
|
||||
|
||||
test('l’ordre des priorités : une entrée à la racine des réglages, sans unité, l’ordre par défaut que lit le stockage', () => {
|
||||
const [ordre] = reglagesDuGenre(REGLAGES_GENERATION, 'ordre');
|
||||
assert.deepEqual({ ...ordre, defaut: null }, {
|
||||
nom: 'priorites',
|
||||
cle: 'priorites',
|
||||
etiquette: 'generation.reglage.priorites',
|
||||
unite: null,
|
||||
defaut: null,
|
||||
genre: 'ordre',
|
||||
section: 'generation',
|
||||
});
|
||||
assert.deepEqual(ordre.defaut, priorites({ reglages: {} }));
|
||||
assert.ok(Object.isFrozen(ordre.defaut));
|
||||
});
|
||||
|
||||
test('la cible de la somme de redondance : un champ de la section des propositions, à la racine des réglages, sous le libellé de son champ, sans unité', () => {
|
||||
|
|
@ -130,8 +205,15 @@ describe('REGLAGES_GENERATION : la définition que lisent les écrans et la docu
|
|||
);
|
||||
assert.deepEqual(
|
||||
reglagesDuGenre(generation, 'bascule').map(({ defaut }) => defaut),
|
||||
[true, true, true, true, false],
|
||||
[true, true, true, true, true, false, false],
|
||||
);
|
||||
// Les cases facultatives, absentes d'une charge neuve : leur défaut est
|
||||
// celui que le stockage leur lit.
|
||||
for (const cle of ['respecterObjectifs', 'rencontrerAnimateurs']) {
|
||||
assert.equal(Object.hasOwn(reglages, cle), false, cle);
|
||||
assert.equal(REGLAGES_GENERATION.find((entree) => entree.cle === cle).defaut, critereCoche({ reglages: {} }, cle), cle);
|
||||
}
|
||||
assert.equal(Object.hasOwn(reglages, 'modeObjectifs') || Object.hasOwn(reglages, 'priorites'), false);
|
||||
});
|
||||
|
||||
test('chaque réglage du schéma du fichier, facultatif compris, a son entrée, et chaque entrée en désigne un : aucun n’échappe aux écrans ni au tableau, hors des réglages nommés sans entrée', () => {
|
||||
|
|
@ -148,10 +230,10 @@ describe('REGLAGES_GENERATION : la définition que lisent les écrans et la docu
|
|||
test('chaque entrée s’écrit par la commande de sa section, et chaque libellé existe', () => {
|
||||
let charge = chargeNeuveEpreuve();
|
||||
for (const entree of REGLAGES_GENERATION) {
|
||||
const valeur = entree.genre === 'bascule' ? !entree.defaut : entree.defaut + 1;
|
||||
const valeur = autreValeur(entree);
|
||||
const [commande, args] = ECRITURES.get(entree.section)(entree, valeur);
|
||||
charge = appliquerCommande(commande, charge, args).charge;
|
||||
assert.equal(valeurDuReglage(charge.reglages, entree), valeur, entree.cle);
|
||||
assert.deepEqual(valeurDuReglage(charge.reglages, entree), valeur, entree.cle);
|
||||
assert.notEqual(t(entree.etiquette), entree.etiquette);
|
||||
}
|
||||
assert.deepEqual([...new Set(REGLAGES_GENERATION.map(({ section }) => section))], [...ECRITURES.keys()]);
|
||||
|
|
@ -173,6 +255,18 @@ describe('valeurDuReglage, reglagesDuGenre et reglagesDeSection', () => {
|
|||
const cible = REGLAGES_GENERATION.find(({ nom }) => nom === 'cibleRedondance');
|
||||
assert.equal(valeurDuReglage({ generation: {} }, cible), CIBLE_REDONDANCE_PAR_DEFAUT);
|
||||
assert.equal(valeurDuReglage({ generation: {} }, { ...cible, defaut: 3 }), 3);
|
||||
for (const entree of REGLAGES_GENERATION.filter(({ genre }) => genre !== 'champ')) {
|
||||
assert.equal(valeurDuReglage({ generation: {} }, entree), entree.defaut, entree.cle);
|
||||
}
|
||||
});
|
||||
|
||||
test('le mode et l’ordre se lisent à la racine des réglages, comme les cases facultatives', () => {
|
||||
const lire = (nom, reglages) => valeurDuReglage(reglages, REGLAGES_GENERATION.find((entree) => entree.nom === nom));
|
||||
const ordre = [[{ critere: 'redondance', poids: 1 }]];
|
||||
assert.equal(lire('modeObjectifs', { modeObjectifs: 'minimum' }), 'minimum');
|
||||
assert.equal(lire('priorites', { priorites: ordre }), ordre);
|
||||
assert.equal(lire('respecterObjectifs', { respecterObjectifs: false }), false);
|
||||
assert.equal(lire('rencontrerAnimateurs', { rencontrerAnimateurs: true }), true);
|
||||
});
|
||||
|
||||
test('un genre inconnu lève, et le nomme', () => {
|
||||
|
|
@ -187,7 +281,7 @@ describe('valeurDuReglage, reglagesDuGenre et reglagesDeSection', () => {
|
|||
);
|
||||
assert.deepEqual(
|
||||
reglagesDeSection(definition, 'generation').slice(0, 2).map(({ nom }) => nom),
|
||||
['attribuerSieges', 'varierAppartenances'],
|
||||
['priorites', 'modeObjectifs'],
|
||||
);
|
||||
assert.deepEqual(reglagesDuGenre([], 'bascule'), []);
|
||||
assert.deepEqual(reglagesDeSection([], 'generation'), []);
|
||||
|
|
|
|||
|
|
@ -2,14 +2,17 @@
|
|||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Le contenu d'un aperçu d'import (§ 10.1, § 10.2), celui que rend
|
||||
// modeleApercu (modele.js) : la source — le nom du fichier, ou le texte
|
||||
// collé —, l'encodage et le séparateur nommés, le refus global avec sa
|
||||
// cause et son remède, l'association des colonnes et ses ambiguïtés, les
|
||||
// lignes valides, les lignes refusées et leur motif, les orthographes
|
||||
// fondues, les doublons, les homonymes d'une mise à jour, les cellules sur
|
||||
// plusieurs lignes. Le rapport le reprend pour l'aperçu appliqué, ses
|
||||
// refus remplacés par ceux du résumé.
|
||||
// Le contenu de l'aperçu d'un import, § 10.1, § 10.2 et § 10.4, celui que
|
||||
// rend modeleApercu (modele.js), de participants ou d'objectifs de table : la
|
||||
// source — le nom du fichier, ou le texte collé —, l'encodage et le
|
||||
// séparateur nommés, le refus global avec sa cause et son remède — texte,
|
||||
// ou commande du geste qui y mène —, l'association des colonnes et ses
|
||||
// ambiguïtés, les lignes valides sous les colonnes de leur nature, les
|
||||
// lignes refusées et leur motif, les orthographes fondues d'appartenances
|
||||
// puis, dans un bloc à part, celles d'étiquettes, les doublons, les
|
||||
// homonymes d'une mise à jour, les cellules sur plusieurs lignes. Le
|
||||
// rapport le reprend pour l'aperçu appliqué, ses refus remplacés par ceux
|
||||
// du résumé. Une cellule d'étiquettes montre chacune en pastille.
|
||||
//
|
||||
// modele : le modèle ; source : { origine: 'fichier'|'collage', nom } ;
|
||||
// participants : ceux de la charge, qui nomment les personnes désignées ;
|
||||
|
|
@ -20,23 +23,29 @@
|
|||
//
|
||||
// Chaque message est classé (Message, messageDuBloc) : le refus global et
|
||||
// les lignes refusées en refus, les doublons, les homonymes et les
|
||||
// cellules sur plusieurs lignes en avis, les orthographes fondues en
|
||||
// information. Les tableaux prennent le langage des cartes : en-tête sur
|
||||
// cellules sur plusieurs lignes en avis, les orthographes fondues,
|
||||
// d'appartenances et d'étiquettes, en information. Les tableaux prennent le langage des cartes : en-tête sur
|
||||
// le fond sourd, lignes séparées d'un filet léger.
|
||||
import { getContext } from 'svelte';
|
||||
import { actionRemede } from '../primitives/commande.js';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Pastille from '../primitives/Pastille.svelte';
|
||||
import Message from './Message.svelte';
|
||||
import { COLONNES_DES_LIGNES, messageDuBloc, motifLigne, nommer } from './modele.js';
|
||||
import { messageDuBloc, motifLigne, nommer } from './modele.js';
|
||||
|
||||
let { modele, source, participants = [], refusees = null, choisir = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const format = getContext('format');
|
||||
const seance = getContext('seance');
|
||||
const ui = getContext('ui') ?? null;
|
||||
const motif = motifLigne(t);
|
||||
|
||||
const refuseesMontrees = $derived(refusees ?? modele.refusees);
|
||||
const global = $derived(modele.global);
|
||||
const refusGlobal = messageDuBloc('global');
|
||||
// La commande du geste qui remédie au refus global, null sans geste.
|
||||
const remedeGlobal = $derived(global?.remede.geste ? actionRemede(global.remede.geste, { seance, ui }) : null);
|
||||
</script>
|
||||
|
||||
<div class="apercu">
|
||||
|
|
@ -65,6 +74,9 @@
|
|||
{#if !global.remede.dansLaCause}
|
||||
<p>{t(global.remede.cle, global.remede.details)}</p>
|
||||
{/if}
|
||||
{#if remedeGlobal !== null}
|
||||
<Commande cle={remedeGlobal.cle} details={remedeGlobal.details} nom={remedeGlobal.nom} executer={remedeGlobal.executer} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
@ -115,7 +127,7 @@
|
|||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t('import.apercu.ligne')}</th>
|
||||
{#each COLONNES_DES_LIGNES as { champ, cle } (champ)}
|
||||
{#each modele.champs as { champ, cle } (champ)}
|
||||
<th scope="col">{t(cle)}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
|
|
@ -124,8 +136,18 @@
|
|||
{#each modele.lignes as { ligne, cellules } (ligne)}
|
||||
<tr data-ligne={ligne}>
|
||||
<th scope="row">{format.nombre(ligne)}</th>
|
||||
{#each cellules as { champ, texte, cle } (champ)}
|
||||
<td data-cellule={champ}>{cle === null ? (texte ?? '') : t(cle)}</td>
|
||||
{#each cellules as { champ, texte, cle, etiquettes } (champ)}
|
||||
<td data-cellule={champ}>
|
||||
{#if etiquettes !== null}
|
||||
{#if etiquettes.length > 0}
|
||||
<ul class="etiquettes">
|
||||
{#each etiquettes as motCle (motCle)}
|
||||
<li><Pastille sorte="statut" valeur="neutre" icone="etiquettes" libelle={motCle} /></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:else}{cle === null ? (texte ?? '') : t(cle)}{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
|
|
@ -189,6 +211,27 @@
|
|||
</Message>
|
||||
{/if}
|
||||
|
||||
{#if modele.fusionsEtiquettes.length > 0}
|
||||
<Message bloc="fusionsEtiquettes" titre={t('import.apercu.fusionsEtiquettes')}>
|
||||
<ul data-fusions-etiquettes>
|
||||
{#each modele.fusionsEtiquettes as fusion (fusion.retenue)}
|
||||
<li>
|
||||
<span>{t('import.apercu.retenue')}</span>
|
||||
<strong data-affichee>{fusion.retenue}</strong>
|
||||
<span>{t('import.apercu.orthographes')}</span>
|
||||
<ul class="orthographes">
|
||||
{#each fusion.orthographes as orthographe (orthographe)}
|
||||
<li data-orthographe><span data-texte>{orthographe}</span></li>
|
||||
{/each}
|
||||
</ul>
|
||||
<span data-nombre>{format.nombre(fusion.lignes)}</span>
|
||||
<span>{t('import.apercu.lignesReecrites', { n: fusion.lignes })}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</Message>
|
||||
{/if}
|
||||
|
||||
{#if modele.doublons.length > 0}
|
||||
<Message bloc="doublons" titre={t('import.apercu.doublons')}>
|
||||
<ul data-doublons>
|
||||
|
|
@ -324,6 +367,13 @@
|
|||
text-align: right;
|
||||
}
|
||||
|
||||
/* Un motif cite la valeur fautive, qu'aucun blanc ne coupe parfois — une
|
||||
étiquette trop longue — : il passe à la ligne n'importe où plutôt que
|
||||
d'élargir la page. */
|
||||
.refusees td {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.defile {
|
||||
max-height: 24rem;
|
||||
overflow: auto;
|
||||
|
|
@ -351,6 +401,26 @@
|
|||
padding-left: var(--espace-5);
|
||||
}
|
||||
|
||||
/* Les orthographes d'une étiquette fondue, en ligne, séparées d'un blanc. */
|
||||
.orthographes {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-2);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* Les étiquettes d'une cellule, une Pastille chacune, celle de la fiche :
|
||||
neutre, à l'icône des étiquettes. La cellule passe à la ligne entre deux
|
||||
pastilles. */
|
||||
.etiquettes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-1);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.personne {
|
||||
display: inline-flex;
|
||||
gap: var(--espace-1);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// L'import des participants dans un vrai moteur de rendu (§ 10.1, § 10.2,
|
||||
// § 10.3, § 2.1 étape 2, § 9) : l'aperçu obligatoire, l'encodage et le
|
||||
// L'import des participants et des objectifs de table dans un vrai moteur de
|
||||
// rendu (§ 10.1, § 10.2, § 10.3, § 10.4, § 2.1 étape 2, § 9) : la nature
|
||||
// choisie, jamais devinée, l'aperçu obligatoire, l'encodage et le
|
||||
// séparateur nommés, l'association par en-tête, l'ambiguïté, les refus
|
||||
// d'une ligne et les refus globaux, le rapport, le CSV des refus, les trois
|
||||
// modes, l'import selon l'état, le collage. Chaque épreuve monte App par
|
||||
// d'une ligne et les refus globaux, les étiquettes en pastilles et leurs
|
||||
// fusions à part, le rapport, le CSV des refus, les modes, l'import selon
|
||||
// l'état, le collage. Chaque épreuve monte App par
|
||||
// monterApp, sur le système de fichiers simulé, ouvre un événement et sa
|
||||
// section d'import ; le dialogue de choix sert, dans l'ordre, les fichiers
|
||||
// que l'épreuve prépare, et le dialogue d'enregistrement relève ce qu'il
|
||||
|
|
@ -29,17 +31,19 @@ import { apercevoir } from '../../csv/apercu.js';
|
|||
import { decoder } from '../../csv/encodage.js';
|
||||
import { bilanRemplacement, exporterRefus as exporterRefusCsv } from '../../csv/import.js';
|
||||
import { decouper } from '../../csv/lecture.js';
|
||||
import { apercevoirObjectifs, bilanRemplacementObjectifs } from '../../csv/objectifs.js';
|
||||
import { serialiser } from '../../stockage/canonique.js';
|
||||
import { creerCharge } from '../../stockage/document.js';
|
||||
import { lireJournal } from '../../stockage/journal.js';
|
||||
import { SUFFIXES } from '../../stockage/noms.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { pointer } from '../../../test/navigateur/entrees.js';
|
||||
import { pointer, touche } from '../../../test/navigateur/entrees.js';
|
||||
import { monterApp } from '../../../test/navigateur/montage.js';
|
||||
import { composantes, visible } from '../../../test/navigateur/rendu.js';
|
||||
import urlCasLimites from '../../../exemples/participants_cas_limites.csv?url';
|
||||
import urlCasLimites1252 from '../../../exemples/participants_cas_limites_cp1252.csv?url';
|
||||
import urlGrande from '../../../exemples/participants_demo_grande.csv?url';
|
||||
import urlObjectifs from '../../../exemples/objectifs_cas_limites.csv?url';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { ENCODAGES, SEPARATEURS } from './modele.js';
|
||||
|
||||
|
|
@ -93,10 +97,12 @@ afterEach(() => {
|
|||
* dialogue de choix sert aServir dans l'ordre ; l'enregistreur relève
|
||||
* enregistres et rend le nom proposé.
|
||||
*/
|
||||
async function ouvrir({ participants = [], ecriture = true, langue = 'fr', siegesParDefaut = 6, prepare = null } = {}) {
|
||||
async function ouvrir({ participants = [], tables = [], ecriture = true, langue = 'fr', siegesParDefaut = 6, prepare = null } = {}) {
|
||||
const charge = creerCharge({ id: 'epreuve-import', nom: 'Soirée des Merles', siegesParDefaut, tours: 2 });
|
||||
charge.participants = participants;
|
||||
charge.prochainsIds.participant = participants.length + 1;
|
||||
charge.tables = tables;
|
||||
charge.prochainsIds.table = tables.length + 1;
|
||||
const texte = serialiser(charge, { revision: 1, produitVersion: VERSION.affichee });
|
||||
const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `${BASE}.gtt.json`, texte }], langue });
|
||||
montes.push(m);
|
||||
|
|
@ -123,7 +129,7 @@ const tous = (e, selecteur) => [...section(e).querySelectorAll(selecteur)];
|
|||
const commande = (e, nom) => dans(e, `[data-commande="${nom}"]`);
|
||||
const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
|
||||
const ecritures = ({ m }) => m.trace.length;
|
||||
const imports = ({ espion }) => espion.mock.calls.filter(([nom]) => nom === 'importerParticipants');
|
||||
const imports = ({ espion }, nom = 'importerParticipants') => espion.mock.calls.filter(([appel]) => appel === nom);
|
||||
|
||||
// Sert un fichier au dialogue et le choisit : l'aperçu s'ouvre sur lui.
|
||||
async function choisir(e, nom, octets) {
|
||||
|
|
@ -146,13 +152,13 @@ function choisirMode(e, mode) {
|
|||
flushSync();
|
||||
}
|
||||
|
||||
// Clique « Importer » et attend la fin de l'appel à la séance qu'il fait :
|
||||
// rend ce que rend seance.executer, ou le refus.
|
||||
async function importer(e) {
|
||||
const avant = imports(e).length;
|
||||
// Clique « Importer » et attend la fin de l'appel à la séance qu'il fait,
|
||||
// la commande nom : rend ce que rend seance.executer, ou le refus.
|
||||
async function importer(e, nom = 'importerParticipants') {
|
||||
const avant = imports(e, nom).length;
|
||||
commande(e, 'importer').click();
|
||||
await vi.waitFor(() => expect(imports(e).length).toBe(avant + 1));
|
||||
const resultat = e.espion.mock.results.filter((_, rang) => e.espion.mock.calls[rang][0] === 'importerParticipants').at(-1);
|
||||
await vi.waitFor(() => expect(imports(e, nom).length).toBe(avant + 1));
|
||||
const resultat = e.espion.mock.results.filter((_, rang) => e.espion.mock.calls[rang][0] === nom).at(-1);
|
||||
try {
|
||||
const rendu = await resultat.value;
|
||||
await vi.waitFor(() => expect(dans(e, '[data-rapport]')).not.toBeNull());
|
||||
|
|
@ -164,12 +170,18 @@ async function importer(e) {
|
|||
}
|
||||
|
||||
// Les lignes valides montrées sous un conteneur : le texte de chaque
|
||||
// cellule, dans l'ordre.
|
||||
// cellule, dans l'ordre. Une cellule d'étiquettes se lit pastille par
|
||||
// pastille, chacune par son libellé, sans l'espace que la Pastille pose
|
||||
// entre son icône et lui.
|
||||
const texteCellule = (cellule) => {
|
||||
const pastilles = [...cellule.querySelectorAll('li')];
|
||||
return pastilles.length === 0 ? cellule.textContent : pastilles.map(texteDe).join('');
|
||||
};
|
||||
const lignesMontrees = (conteneur) =>
|
||||
[...conteneur.querySelectorAll('[data-lignes] [data-ligne]')].map((ligne) =>
|
||||
[...ligne.querySelectorAll('[data-cellule]')].map((cellule) => cellule.textContent));
|
||||
[...ligne.querySelectorAll('[data-cellule]')].map(texteCellule));
|
||||
const cellule = (conteneur, ligne, champ) =>
|
||||
conteneur.querySelector(`[data-lignes] [data-ligne="${ligne}"] [data-cellule="${champ}"]`).textContent;
|
||||
texteCellule(conteneur.querySelector(`[data-lignes] [data-ligne="${ligne}"] [data-cellule="${champ}"]`));
|
||||
const refusee = (conteneur, ligne) => conteneur.querySelector(`[data-refusees] [data-ligne="${ligne}"]`);
|
||||
const association = (conteneur) =>
|
||||
[...conteneur.querySelectorAll('[data-colonne]')].map((colonne) => [
|
||||
|
|
@ -213,6 +225,17 @@ function vu(element) {
|
|||
return visible(element);
|
||||
}
|
||||
|
||||
// La couleur qu'un jeton donne dans le thème courant, relue sur une sonde.
|
||||
function jeton(nom) {
|
||||
const sonde = document.createElement('span');
|
||||
sonde.style.color = `var(${nom})`;
|
||||
document.body.append(sonde);
|
||||
const couleur = composantes(getComputedStyle(sonde).color);
|
||||
sonde.remove();
|
||||
return couleur;
|
||||
}
|
||||
const fondDe = (element) => composantes(getComputedStyle(element).backgroundColor);
|
||||
|
||||
describe('l’aperçu est obligatoire (§ 10.1)', () => {
|
||||
test('choisir les cas limites ouvre l’aperçu, sans rien écrire ; déposer un fichier sur la fenêtre ouvre son aperçu ; aucun contrôle hors de l’import n’importe', async () => {
|
||||
const e = await ouvrir();
|
||||
|
|
@ -911,16 +934,6 @@ describe('les cas du § 10.3', () => {
|
|||
});
|
||||
|
||||
describe('l’habillage : cartes et messages classés en refus, avis ou information', () => {
|
||||
// La couleur qu'un jeton donne dans le thème courant, relue sur une sonde.
|
||||
function jeton(nom) {
|
||||
const sonde = document.createElement('span');
|
||||
sonde.style.color = `var(${nom})`;
|
||||
document.body.append(sonde);
|
||||
const couleur = composantes(getComputedStyle(sonde).color);
|
||||
sonde.remove();
|
||||
return couleur;
|
||||
}
|
||||
const fondDe = (element) => composantes(getComputedStyle(element).backgroundColor);
|
||||
const nomDe = (region) => texteDe(document.getElementById(region.getAttribute('aria-labelledby')));
|
||||
|
||||
// La classe d'un message, lue dans son bloc : la pastille dont le texte
|
||||
|
|
@ -941,9 +954,11 @@ describe('l’habillage : cartes et messages classés en refus, avis ou informat
|
|||
exigerSorte(bloc('[data-refusees]'), 'refus', `${lieu}, lignes refusées`);
|
||||
exigerSorte(bloc('[data-doublons]'), 'avis', `${lieu}, doublons`);
|
||||
exigerSorte(bloc('[data-fusions]'), 'information', `${lieu}, orthographes fondues`);
|
||||
exigerSorte(bloc('[data-fusions-etiquettes]'), 'information', `${lieu}, étiquettes fondues`);
|
||||
expect(bloc('[data-fusions-etiquettes]'), lieu).not.toBe(bloc('[data-fusions]'));
|
||||
}
|
||||
|
||||
test('les cas limites : refusées en refus, doublons en avis, fusions en information, dans l’aperçu puis le rapport', async () => {
|
||||
test('les cas limites : refusées en refus, doublons en avis, fusions d’appartenances et d’étiquettes en information, chacune son bloc, dans l’aperçu puis le rapport', async () => {
|
||||
const e = await ouvrir();
|
||||
await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites));
|
||||
exigerBlocs(dans(e, '[data-apercu]'), 'aperçu');
|
||||
|
|
@ -976,6 +991,21 @@ describe('l’habillage : cartes et messages classés en refus, avis ou informat
|
|||
exigerSorte(rien, 'information', 'rapport, rien d’écrit');
|
||||
});
|
||||
|
||||
// Un import d'objectifs n'écrit rien quand chaque couple du fichier porte
|
||||
// déjà son objectif et qu'il refuse des lignes : ici, la table 1 porte
|
||||
// déjà « finance », et aucune table n'affiche le numéro 9.
|
||||
test('un import d’objectifs qui n’a rien écrit le dit en information, avec le texte des objectifs', async () => {
|
||||
const e = await ouvrir({ tables: [table(1, 1, [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }]), table(2, 2)] });
|
||||
choisirNature(e, 'objectifs');
|
||||
await choisir(e, 'inchange.csv', enUtf8('table;objectif\r\n1;finance\r\n9;finance\r\n'));
|
||||
const { rendu } = await importer(e, 'importerObjectifs');
|
||||
expect(rendu.libelle).toBeNull();
|
||||
const rapport = dans(e, '[data-rapport]');
|
||||
const rien = [...rapport.querySelectorAll('section')].find((bloc) => texteDe(bloc).includes(t('import.rapport.rienEcritObjectifs'))) ?? null;
|
||||
exigerSorte(rien, 'information', 'rapport des objectifs, rien d’écrit');
|
||||
expect(texteDe(rapport)).not.toContain(t('import.rapport.rienEcrit'));
|
||||
});
|
||||
|
||||
test('un refus global porte la pastille du refus, sa cause et son remède', async () => {
|
||||
const e = await ouvrir();
|
||||
await coller(e, 'Ombrelle\tIris\nPervenche\tThéo');
|
||||
|
|
@ -997,3 +1027,363 @@ describe('l’habillage : cartes et messages classés en refus, avis ou informat
|
|||
expect(regions()).not.toContain(t('import.apercu.titre'));
|
||||
});
|
||||
});
|
||||
|
||||
// --- La nature du fichier, les objectifs de table, les étiquettes (§ 10.1, § 10.4) ---
|
||||
|
||||
// Une table de la charge d'épreuve, ses objectifs donnés.
|
||||
const table = (id, numero, objectifs = null) => ({
|
||||
id, numero, sieges: null, forme: 'ronde', position: { x: 0, y: 0 }, ...(objectifs === null ? {} : { objectifs }),
|
||||
});
|
||||
// Neuf tables numérotées 1, 2, 3, 3, puis 4 à 8 : deux portent le numéro 3.
|
||||
const NEUF_TABLES = () => [1, 2, 3, 3, 4, 5, 6, 7, 8].map((numero, rang) => table(rang + 1, numero));
|
||||
|
||||
// Le CSV des objectifs qui refuse une ligne pour chaque motif du § 10.4,
|
||||
// dans l'ordre du texte ; huit lignes valides précèdent le champ en trop,
|
||||
// que l'analyse du séparateur ne lit qu'au-delà de ses vingt premiers
|
||||
// enregistrements.
|
||||
const VINGT_ET_UNE = Array.from({ length: 21 }, (_, rang) => `mot${rang + 1}`).join('|');
|
||||
const TEXTE_DES_MOTIFS = [
|
||||
'table;tour;objectif;regle',
|
||||
'1;;finance;',
|
||||
';;finance;',
|
||||
'un;;finance;',
|
||||
'9;;finance;',
|
||||
'3;;finance;',
|
||||
'2;2.0;finance;',
|
||||
'2;5;finance;',
|
||||
'2;1;;',
|
||||
`2;1;${'x'.repeat(41)};`,
|
||||
`2;1;${VINGT_ET_UNE};`,
|
||||
'2;1;finance;peut-être',
|
||||
'1;;Finance;et',
|
||||
'2;2;FINANCE;toutes',
|
||||
'4;;bénévole;ou',
|
||||
'4;1;bénévole;',
|
||||
'4;2;bénévole;',
|
||||
'5;;bénévole;',
|
||||
'5;1;bénévole;',
|
||||
'5;2;bénévole;',
|
||||
'6;;bénévole;',
|
||||
'6;1;bénévole;une',
|
||||
'1;1;finance;;en trop',
|
||||
'',
|
||||
].join('\r\n');
|
||||
const MOTIFS_DU_10_4 = [
|
||||
'TABLE_ABSENTE', 'TABLE_NON_ENTIERE', 'NUMERO_TABLE_INCONNU', 'PLUSIEURS_TABLES', 'TOUR_NON_ENTIER', 'TOUR_HORS_SOIREE',
|
||||
'OBJECTIF_VIDE', 'ETIQUETTE_MAL_FORMEE', 'ETIQUETTES_TROP_NOMBREUSES', 'REGLE_INCONNUE', 'OBJECTIF_EN_DOUBLE', 'CHAMPS_EN_TROP',
|
||||
];
|
||||
|
||||
// Coche une nature du fichier.
|
||||
function choisirNature(e, nature) {
|
||||
dans(e, `[data-nature-import] input[value="${nature}"]`).click();
|
||||
flushSync();
|
||||
}
|
||||
const natureCochee = (e) => dans(e, '[data-nature-import] input:checked')?.value ?? null;
|
||||
|
||||
// Les étiquettes montrées en pastilles dans une cellule de l'aperçu.
|
||||
const pastilles = (conteneur, ligne, champ) =>
|
||||
[...conteneur.querySelectorAll(`[data-lignes] [data-ligne="${ligne}"] [data-cellule="${champ}"] li`)].map(texteDe);
|
||||
|
||||
// Un premier geste, sans effet sur les objectifs ni sur les tables : le
|
||||
// journal existe avant l'import, dont l'entrée se compte seule.
|
||||
const premierGeste = (seance) => seance.executer('changerRegleObjectifs', { regle: 'et' });
|
||||
|
||||
// Les comptes du rapport d'un import d'objectifs, affichés puis attendus.
|
||||
const COMPTES_DES_OBJECTIFS = [
|
||||
['ajoutes', 'import.rapport.objectifsAjoutes'],
|
||||
['remplaces', 'import.rapport.objectifsRemplaces'],
|
||||
['inchanges', 'import.rapport.objectifsInchanges'],
|
||||
['retires', 'import.rapport.objectifsRetires'],
|
||||
['tables', 'import.rapport.tablesTouchees'],
|
||||
];
|
||||
const comptesObjectifsAffiches = (e) =>
|
||||
COMPTES_DES_OBJECTIFS.map(([nom]) => {
|
||||
const compte = dans(e, `[data-rapport] [data-compte="${nom}"]`);
|
||||
return [nom, compte.querySelector('[data-nombre]').textContent, compte.querySelector('[data-nombre] + span').textContent];
|
||||
});
|
||||
const comptesObjectifsDu = (resume) =>
|
||||
COMPTES_DES_OBJECTIFS.map(([nom, cle]) => [nom, format.nombre(resume[nom]), t(cle, { n: resume[nom] })]);
|
||||
|
||||
describe('la nature du fichier : choisie avant l’aperçu, jamais devinée (§ 10.4)', () => {
|
||||
test('Participants est cochée par défaut, avant tout fichier ; le CSV des objectifs livré, lu sous Participants, est refusé sans commande d’import ; coché Objectifs, le même texte se lit en objectifs', async () => {
|
||||
const e = await ouvrir({ tables: NEUF_TABLES() });
|
||||
expect(dans(e, '[data-apercu]')).toBeNull();
|
||||
const choix = dans(e, '[data-nature-import]');
|
||||
expect(vu(choix)).toBe(true);
|
||||
expect(natureCochee(e)).toBe('participants');
|
||||
expect([...choix.querySelectorAll('label')].map(texteDe)).toEqual([t('import.nature.participants'), t('import.nature.objectifs')]);
|
||||
expect(texteDe(choix.querySelector('legend'))).toBe(t('import.nature.titre'));
|
||||
|
||||
const octets = await lireLivre(urlObjectifs);
|
||||
await choisir(e, 'objectifs_cas_limites.csv', octets);
|
||||
expect(natureCochee(e)).toBe('participants');
|
||||
expect(dans(e, '[data-apercu] [data-refus="AUCUN_ENTETE"]')).not.toBeNull();
|
||||
expect(commande(e, 'importer')).toBeNull();
|
||||
expect(tous(e, '[data-apercu] [data-champ="mode"]')).toEqual([]);
|
||||
|
||||
choisirNature(e, 'objectifs');
|
||||
expect(natureCochee(e)).toBe('objectifs');
|
||||
expect(dans(e, '[data-apercu] [data-fichier]').textContent).toBe('objectifs_cas_limites.csv');
|
||||
expect(dans(e, '[data-apercu] [data-refus]')).toBeNull();
|
||||
const apercu = apercevoirObjectifs(decoder(octets).texte, e.m.seance.etat().charge);
|
||||
expect(association(dans(e, '[data-apercu]'))).toEqual([
|
||||
['Table', t('import.champ.table')],
|
||||
['Tour', t('import.champ.tour')],
|
||||
['Objectif', t('import.champ.objectif')],
|
||||
['Règle', t('import.champ.regle')],
|
||||
]);
|
||||
expect(tous(e, '[data-apercu] [data-lignes] [data-ligne]').map((ligne) => Number(ligne.dataset.ligne))).toEqual(
|
||||
apercu.lignes.map(({ ligne }) => ligne),
|
||||
);
|
||||
expect(tous(e, '[data-apercu] [data-refusees] [data-ligne]').map((ligne) => Number(ligne.dataset.ligne))).toEqual(
|
||||
apercu.refusees.map(({ ligne }) => ligne),
|
||||
);
|
||||
expect([...dans(e, '[data-champ="mode"]').querySelectorAll('input')].map((input) => input.value)).toEqual(['mettreAJour', 'remplacer']);
|
||||
expect(dans(e, '[data-champ="mode"] input:checked').value).toBe('mettreAJour');
|
||||
expect(commande(e, 'importer')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('une liste de participants qui porte une colonne « Table », lue sous Participants : la table ignorée, des personnes ; sous Objectifs, refusée faute d’objectif', async () => {
|
||||
const e = await ouvrir({ tables: NEUF_TABLES() });
|
||||
await choisir(e, 'invites.csv', enUtf8('nom;prenom;table\r\nOmbrelle;Iris;2\r\nPervenche;Théo;1\r\n'));
|
||||
expect(association(dans(e, '[data-apercu]')).at(-1)).toEqual(['table', t('import.colonne.nonReconnue')]);
|
||||
expect(lignesMontrees(dans(e, '[data-apercu]')).map(([nom]) => nom)).toEqual(['Ombrelle', 'Pervenche']);
|
||||
choisirNature(e, 'objectifs');
|
||||
expect(dans(e, '[data-apercu] [data-refus="OBJECTIF_NON_ASSOCIE"]')).not.toBeNull();
|
||||
expect(texteDe(dans(e, '[data-apercu] [data-refus]'))).toContain(t('import.remede.objectifNonAssocie'));
|
||||
expect(commande(e, 'importer')).toBeNull();
|
||||
choisirNature(e, 'participants');
|
||||
expect(dans(e, '[data-apercu] [data-refus]')).toBeNull();
|
||||
expect(dans(e, '[data-champ="mode"] input:checked').value).toBe('ajouter');
|
||||
});
|
||||
|
||||
test('au clavier : la flèche passe de Participants à Objectifs de table, l’aperçu ouvert se relit, et le bouton qui a le focus porte l’anneau de l’application', async () => {
|
||||
const e = await ouvrir({ tables: NEUF_TABLES() });
|
||||
await choisir(e, 'objectifs_cas_limites.csv', await lireLivre(urlObjectifs));
|
||||
dans(e, '[data-nature-import] input[value="participants"]').focus();
|
||||
await touche('ArrowDown');
|
||||
await vi.waitFor(() => expect(natureCochee(e)).toBe('objectifs'));
|
||||
const actif = document.activeElement;
|
||||
expect(actif.value).toBe('objectifs');
|
||||
expect(actif.matches(':focus-visible')).toBe(true);
|
||||
const style = getComputedStyle(actif);
|
||||
expect(style.outlineStyle).toBe('solid');
|
||||
const sonde = document.createElement('span');
|
||||
sonde.style.color = 'var(--couleur-focus)';
|
||||
document.body.append(sonde);
|
||||
expect(style.outlineColor).toBe(getComputedStyle(sonde).color);
|
||||
sonde.remove();
|
||||
await vi.waitFor(() => expect(dans(e, '[data-apercu] [data-refus]')).toBeNull());
|
||||
expect(commande(e, 'importer')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('un collage de la grille coche Participants, quelle que soit la nature cochée', async () => {
|
||||
const e = await ouvrir({ tables: NEUF_TABLES() });
|
||||
choisirNature(e, 'objectifs');
|
||||
await coller(e, 'nom\tprenom\nOmbrelle\tIris\n');
|
||||
expect(natureCochee(e)).toBe('participants');
|
||||
expect(lignesMontrees(dans(e, '[data-apercu]')).map(([nom]) => nom)).toEqual(['Ombrelle']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('l’aperçu des objectifs de table (§ 10.4)', () => {
|
||||
test('un refus de chaque motif du § 10.4 rendu dans l’aperçu, à son rang, son motif celui de t ; les lignes valides montrent numéro, tour, étiquettes en pastilles et règle', async () => {
|
||||
const e = await ouvrir({ tables: NEUF_TABLES() });
|
||||
choisirNature(e, 'objectifs');
|
||||
await choisir(e, 'motifs.csv', enUtf8(TEXTE_DES_MOTIFS));
|
||||
const apercu = apercevoirObjectifs(TEXTE_DES_MOTIFS, e.m.seance.etat().charge);
|
||||
expect(apercu.refusees.map(({ code }) => code)).toEqual(MOTIFS_DU_10_4);
|
||||
const ici = dans(e, '[data-apercu]');
|
||||
for (const { code, brut, ...details } of apercu.refusees) {
|
||||
const ligne = refusee(ici, details.ligne);
|
||||
expect(ligne, code).not.toBeNull();
|
||||
expect(vu(ligne), code).toBe(true);
|
||||
expect(ligne.querySelector('[data-motif]').textContent, code).toBe(t(code, details));
|
||||
}
|
||||
const [{ retenue }] = apercu.fusionsEtiquettes;
|
||||
expect(lignesMontrees(ici)[0]).toEqual(['1', t('import.tour.soiree'), retenue, t('import.regle.defaut')]);
|
||||
expect(cellule(ici, 14, 'tour')).toBe('2');
|
||||
expect(pastilles(ici, 14, 'objectif')).toEqual([retenue]);
|
||||
expect(cellule(ici, 14, 'regle')).toBe(t('import.regle.et'));
|
||||
expect(cellule(ici, 15, 'regle')).toBe(t('import.regle.ou'));
|
||||
expect(texteDe(dans(e, '[data-apercu] [data-valides]'))).toContain(format.nombre(apercu.lignes.length));
|
||||
});
|
||||
|
||||
test('« finance » et « FINANCE » fondus en une orthographe : le bloc des étiquettes fondues, en information, nomme la retenue, les orthographes et les lignes réécrites ; aucun bloc d’appartenances', async () => {
|
||||
const e = await ouvrir({ tables: NEUF_TABLES() });
|
||||
choisirNature(e, 'objectifs');
|
||||
await choisir(e, 'motifs.csv', enUtf8(TEXTE_DES_MOTIFS));
|
||||
const [fusion] = apercevoirObjectifs(TEXTE_DES_MOTIFS, e.m.seance.etat().charge).fusionsEtiquettes;
|
||||
const bloc = dans(e, '[data-apercu] [data-fusions-etiquettes]');
|
||||
expect(vu(bloc)).toBe(true);
|
||||
expect(bloc.querySelector('[data-affichee]').textContent).toBe(fusion.retenue);
|
||||
expect([...bloc.querySelectorAll('[data-orthographe] [data-texte]')].map((o) => o.textContent)).toEqual(fusion.orthographes);
|
||||
expect(bloc.querySelector(':scope > li > [data-nombre]').textContent).toBe(format.nombre(fusion.lignes));
|
||||
expect(texteDe(bloc.closest('section'))).toContain(t('import.apercu.fusionsEtiquettes'));
|
||||
expect(texteDe(bloc.closest('section'))).toContain(t('import.sorte.information'));
|
||||
expect(dans(e, '[data-apercu] [data-fusions]')).toBeNull();
|
||||
});
|
||||
|
||||
test('un événement sans table : OBJECTIFS_SANS_TABLE, et son remède ouvre la section des tables', async () => {
|
||||
const e = await ouvrir();
|
||||
choisirNature(e, 'objectifs');
|
||||
await choisir(e, 'objectifs_cas_limites.csv', await lireLivre(urlObjectifs));
|
||||
const pose = dans(e, '[data-apercu] [data-refus="OBJECTIFS_SANS_TABLE"]');
|
||||
expect(vu(pose)).toBe(true);
|
||||
expect(texteDe(pose)).toContain(t('OBJECTIFS_SANS_TABLE'));
|
||||
const remede = pose.querySelector('[data-commande="ouvrirSection"]');
|
||||
expect(remede.textContent.trim()).toBe(t('communs.remede.ouvrirSection.tables'));
|
||||
expect(commande(e, 'importer')).toBeNull();
|
||||
remede.click();
|
||||
await vi.waitFor(() => expect(e.m.ui.etat().section).toBe('tables'));
|
||||
});
|
||||
|
||||
test('chaque refus global propre aux objectifs : sa cause et son remède, aucune commande d’import, rien d’écrit', async () => {
|
||||
const e = await ouvrir({ tables: NEUF_TABLES() });
|
||||
choisirNature(e, 'objectifs');
|
||||
const avant = ecritures(e);
|
||||
const cas = [
|
||||
['AUCUN_ENTETE', 'a;b\r\n1;finance\r\n', 'import.remede.aucunEnteteObjectifs'],
|
||||
['TABLE_NON_ASSOCIEE', 'tour;objectif\r\n1;finance\r\n', 'import.remede.tableNonAssociee'],
|
||||
['OBJECTIF_NON_ASSOCIE', 'table;tour\r\n1;1\r\n', 'import.remede.objectifNonAssocie'],
|
||||
['AUCUNE_LIGNE_VALIDE', 'table;objectif\r\n9;finance\r\n', 'import.remede.aucuneLigneValide'],
|
||||
];
|
||||
for (const [code, texte, remede] of cas) {
|
||||
await choisir(e, `${code}.csv`, enUtf8(texte));
|
||||
const pose = dans(e, `[data-apercu] [data-refus="${code}"]`);
|
||||
expect(pose, code).not.toBeNull();
|
||||
expect(texteDe(pose), code).toContain(t(remede));
|
||||
expect(commande(e, 'importer'), code).toBeNull();
|
||||
expect(natureCochee(e), code).toBe('objectifs');
|
||||
}
|
||||
expect(ecritures(e)).toBe(avant);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importer des objectifs de table (§ 10.4)', () => {
|
||||
test('mettre à jour : une entrée, importerObjectifs sous l’aperçu montré ; le rapport porte les comptes du résumé et le bloc des étiquettes fondues ; le CSV des refus est celui d’exporterRefus', async () => {
|
||||
const e = await ouvrir({ tables: NEUF_TABLES(), prepare: premierGeste });
|
||||
choisirNature(e, 'objectifs');
|
||||
await choisir(e, 'motifs.csv', enUtf8(TEXTE_DES_MOTIFS));
|
||||
const apercu = apercevoirObjectifs(TEXTE_DES_MOTIFS, e.m.seance.etat().charge);
|
||||
commande(e, 'enregistrerRefus').click();
|
||||
await vi.waitFor(() => expect(e.enregistres.length).toBe(1));
|
||||
expect(e.enregistres[0].nomPropose).toBe(`${BASE}-refus.csv`);
|
||||
expect([...e.enregistres[0].octets]).toEqual([...exporterRefusCsv(apercu, libelle)]);
|
||||
|
||||
const entreesAvant = entrees(e);
|
||||
const { rendu } = await importer(e, 'importerObjectifs');
|
||||
expect(imports(e)).toEqual([]);
|
||||
const [[, argumentsRecus]] = imports(e, 'importerObjectifs');
|
||||
expect(argumentsRecus.mode).toBe('mettreAJour');
|
||||
expect(argumentsRecus.apercu).toEqual(apercu);
|
||||
expect(entrees(e)).toBe(entreesAvant + 1);
|
||||
expect(rendu.resume).toMatchObject({ ajoutes: 10, remplaces: 0, inchanges: 0, retires: 0 });
|
||||
expect(comptesObjectifsAffiches(e)).toEqual(comptesObjectifsDu(rendu.resume));
|
||||
const rapport = dans(e, '[data-rapport]');
|
||||
expect(vu(rapport.querySelector('h3'))).toBe(true);
|
||||
expect(rapport.querySelector('[data-fusions-etiquettes]')).not.toBeNull();
|
||||
expect(rapport.querySelectorAll('[data-refusees] [data-ligne]').length).toBe(MOTIFS_DU_10_4.length);
|
||||
const tables = e.m.seance.etat().charge.tables;
|
||||
const [{ retenue }] = apercu.fusionsEtiquettes;
|
||||
expect(tables.find(({ id }) => id === 1).objectifs).toEqual([{ portee: 'tous', tour: null, regle: null, etiquettes: [retenue] }]);
|
||||
|
||||
// L'avis de A20 : l'événement n'a aucun participant, donc aucun des dix
|
||||
// objectifs écrits n'a de correspondant. La commande le rend, la séance
|
||||
// le relaie, la liste des avertissements de l'événement le montre.
|
||||
expect(rendu.avertissements.map(({ code }) => code)).toEqual(Array(10).fill('OBJECTIF_SANS_CORRESPONDANT'));
|
||||
const montres = [...e.m.racine.querySelectorAll('[data-avertissement="OBJECTIF_SANS_CORRESPONDANT"]')];
|
||||
expect(montres.map((avis) => texteDe(avis.querySelector('p')))).toEqual(rendu.avertissements.map(({ code, details }) => t(code, details)));
|
||||
expect(vu(montres[0])).toBe(true);
|
||||
});
|
||||
|
||||
test('remplacer annonce ses deux comptes, objectifs et tables ; Annuler n’écrit rien ; Confirmer, une entrée, et les objectifs que le fichier ne nomme pas partent', async () => {
|
||||
const soiree = (etiquettes) => ({ portee: 'tous', tour: null, regle: null, etiquettes });
|
||||
const tables = [
|
||||
table(1, 1, [soiree(['bénévole']), { portee: 'tour', tour: 2, regle: 'et', etiquettes: ['Montréal', 'finance'] }]),
|
||||
table(2, 2, [soiree(['ressources humaines'])]),
|
||||
table(3, 3),
|
||||
];
|
||||
const e = await ouvrir({ tables, prepare: premierGeste });
|
||||
choisirNature(e, 'objectifs');
|
||||
await choisir(e, 'remplace.csv', enUtf8('table;objectif\r\n3;finance\r\n'));
|
||||
choisirMode(e, 'remplacer');
|
||||
const bilan = bilanRemplacementObjectifs(e.m.seance.etat().charge);
|
||||
expect(bilan).toEqual({ objectifs: 3, tables: 2 });
|
||||
const attendus = [t('import.remplacer.objectifs', { n: 3 }), t('import.remplacer.tables', { n: 2 })];
|
||||
expect(attendus).toEqual(['3 objectifs de table seront supprimés', '2 tables perdront leurs objectifs']);
|
||||
const avant = ecritures(e);
|
||||
commande(e, 'importer').click();
|
||||
await vi.waitFor(() => expect(dans(e, 'dialog')?.open).toBe(true));
|
||||
expect(texteDe(dans(e, 'dialog'))).toContain(t('import.remplacer.titreObjectifs'));
|
||||
expect([...dans(e, 'dialog').querySelectorAll('li')].map((li) => li.textContent)).toEqual(attendus);
|
||||
dans(e, 'dialog [data-commande="renoncer"]').click();
|
||||
await vi.waitFor(() => expect(dans(e, 'dialog')).toBeNull());
|
||||
expect(imports(e, 'importerObjectifs')).toEqual([]);
|
||||
expect(ecritures(e)).toBe(avant);
|
||||
|
||||
const entreesAvant = entrees(e);
|
||||
commande(e, 'importer').click();
|
||||
await vi.waitFor(() => expect(dans(e, 'dialog')?.open).toBe(true));
|
||||
dans(e, 'dialog [data-commande="confirmer"]').click();
|
||||
await vi.waitFor(() => expect(dans(e, '[data-rapport]')).not.toBeNull());
|
||||
expect(imports(e, 'importerObjectifs').map(([, { mode }]) => mode)).toEqual(['remplacer']);
|
||||
expect(entrees(e)).toBe(entreesAvant + 1);
|
||||
const { resume } = await e.espion.mock.results.at(-1).value;
|
||||
expect(resume).toMatchObject({ ajoutes: 1, remplaces: 0, inchanges: 0, retires: 3, tables: 3 });
|
||||
expect(comptesObjectifsAffiches(e)).toEqual(comptesObjectifsDu(resume));
|
||||
expect(e.m.seance.etat().charge.tables.map(({ objectifs = [] }) => objectifs.length)).toEqual([0, 0, 1]);
|
||||
});
|
||||
|
||||
test('bloqué : l’aperçu des objectifs s’ouvre, l’import est refusé à côté de la commande, rien d’écrit', async () => {
|
||||
const e = await ouvrir({ tables: NEUF_TABLES(), prepare: (seance) => seance.executer('changerEtat', { etat: 'bloque' }) });
|
||||
choisirNature(e, 'objectifs');
|
||||
const avant = ecritures(e);
|
||||
await choisir(e, 'objectifs_cas_limites.csv', await lireLivre(urlObjectifs));
|
||||
const { refus } = await importer(e, 'importerObjectifs');
|
||||
expect(refus.code).toBe('LECTURE_SEULE');
|
||||
expect(commande(e, 'importer').parentElement.contains(dans(e, '[data-refus="LECTURE_SEULE"]'))).toBe(true);
|
||||
expect(ecritures(e)).toBe(avant);
|
||||
});
|
||||
});
|
||||
|
||||
describe('les étiquettes des participants dans l’aperçu (§ 10.1, A17)', () => {
|
||||
test('les cas limites : chaque cellule d’étiquettes en pastilles, sous l’orthographe retenue ; le bloc des étiquettes fondues présent, à part des appartenances, dans l’aperçu puis le rapport', async () => {
|
||||
const e = await ouvrir();
|
||||
const octets = await lireLivre(urlCasLimites);
|
||||
await choisir(e, 'participants_cas_limites.csv', octets);
|
||||
const apercu = apercevoir(decoder(octets).texte);
|
||||
expect(apercu.fusionsEtiquettes.length).toBeGreaterThanOrEqual(1);
|
||||
const ici = dans(e, '[data-apercu]');
|
||||
for (const { ligne, champs } of apercu.lignes) expect(pastilles(ici, ligne, 'etiquettes'), `ligne ${ligne}`).toEqual(champs.etiquettes ?? []);
|
||||
expect(apercu.lignes.some(({ champs }) => (champs.etiquettes ?? []).length > 1)).toBe(true);
|
||||
// Chaque étiquette, la pastille de la fiche : neutre, à icône, de
|
||||
// l'encre de la page ; son fond se détache de celui de la cellule.
|
||||
const elements = [...ici.querySelectorAll('[data-cellule="etiquettes"] li')];
|
||||
expect(elements.length).toBeGreaterThan(1);
|
||||
for (const li of elements) {
|
||||
const pastille = [li, ...li.querySelectorAll('*')].find((element) => fondDe(element)[3] > 0);
|
||||
expect(fondDe(pastille), texteDe(li)).toEqual(jeton('--couleur-neutre-fond'));
|
||||
expect(li.querySelector('svg'), texteDe(li)).not.toBeNull();
|
||||
expect(composantes(getComputedStyle(pastille).color), texteDe(li)).toEqual(jeton('--couleur-page-texte'));
|
||||
}
|
||||
expect(vu(elements[0])).toBe(true);
|
||||
expect(fondDe(elements[0].closest('td'))).not.toEqual(jeton('--couleur-neutre-fond'));
|
||||
|
||||
for (const lieu of ['[data-apercu]', '[data-rapport]']) {
|
||||
if (lieu === '[data-rapport]') await importer(e);
|
||||
const conteneur = dans(e, lieu);
|
||||
const bloc = conteneur.querySelector('[data-fusions-etiquettes]');
|
||||
expect(bloc, lieu).not.toBeNull();
|
||||
expect([...bloc.querySelectorAll('[data-affichee]')].map((a) => a.textContent), lieu).toEqual(
|
||||
apercu.fusionsEtiquettes.map(({ retenue }) => retenue),
|
||||
);
|
||||
const appartenances = conteneur.querySelector('[data-fusions]');
|
||||
expect(appartenances.contains(bloc), lieu).toBe(false);
|
||||
expect(bloc.contains(appartenances), lieu).toBe(false);
|
||||
const retenues = new Set(apercu.fusionsEtiquettes.map(({ retenue }) => retenue));
|
||||
for (const affichee of appartenances.querySelectorAll('[data-affichee]')) expect(retenues.has(affichee.textContent), lieu).toBe(false);
|
||||
expect(appartenances.querySelectorAll('[data-fusion]').length, lieu).toBe(apercu.fusions.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,40 +2,40 @@
|
|||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Le rapport d'un import appliqué (§ 10.1) : les comptes du résumé que
|
||||
// rend seance.executer — ajoutés, mis à jour, inchangés, retirés,
|
||||
// réservations retirées —, puis l'aperçu appliqué, ses lignes refusées
|
||||
// remplacées par resume.refusees : celles de l'aperçu, puis celles de la
|
||||
// mise à jour, chacune à son rang, avec les personnes qu'elle désigne. Un
|
||||
// import qui n'a rien écrit le dit.
|
||||
// Le rapport d'un import appliqué (§ 10.1, § 10.4) : les comptes du résumé
|
||||
// que rend seance.executer, ceux de sa nature (comptesDuRapport) — pour des
|
||||
// participants, ajoutés, mis à jour, inchangés, retirés, réservations
|
||||
// retirées ; pour des objectifs, ajoutés, remplacés, inchangés, retirés,
|
||||
// tables dont les objectifs changent —, puis l'aperçu appliqué, ses lignes
|
||||
// refusées remplacées par resume.refusees : celles de l'aperçu, puis celles
|
||||
// de la mise à jour, chacune à son rang, avec les personnes qu'elle
|
||||
// désigne. Un import qui n'a rien écrit le dit.
|
||||
//
|
||||
// Le rapport est une carte titrée ; ses comptes, des chiffres tabulaires
|
||||
// en tête de chaque libellé ; un import qui n'a rien écrit le dit en
|
||||
// information (Message). Ses commandes, celles de la section, viennent
|
||||
// en pied de carte (commandes, un snippet).
|
||||
//
|
||||
// rapport : { modele, source, resume, libelle } ; participants : ceux de
|
||||
// la charge écrite.
|
||||
// rapport : { nature, modele, source, resume, libelle } ; participants :
|
||||
// ceux de la charge écrite.
|
||||
import { getContext } from 'svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import Apercu from './Apercu.svelte';
|
||||
import Message from './Message.svelte';
|
||||
import { refuseesDe } from './modele.js';
|
||||
import { comptesDuRapport, refuseesDe } from './modele.js';
|
||||
|
||||
let { rapport, participants, commandes = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const format = getContext('format');
|
||||
|
||||
// Chaque compte du résumé et la clé de son libellé, dans l'ordre du résumé.
|
||||
const COMPTES = [
|
||||
['ajoutes', 'import.rapport.ajoutes'],
|
||||
['misAJour', 'import.rapport.misAJour'],
|
||||
['inchanges', 'import.rapport.inchanges'],
|
||||
['retires', 'import.rapport.retires'],
|
||||
['reservationsRetirees', 'import.rapport.reservationsRetirees'],
|
||||
];
|
||||
// Ce qu'un import qui n'a rien écrit dit, par nature.
|
||||
const RIEN_ECRIT = new Map([
|
||||
['participants', 'import.rapport.rienEcrit'],
|
||||
['objectifs', 'import.rapport.rienEcritObjectifs'],
|
||||
]);
|
||||
|
||||
const comptes = $derived(comptesDuRapport(rapport.nature));
|
||||
const refusees = $derived(refuseesDe(rapport.resume.refusees));
|
||||
</script>
|
||||
|
||||
|
|
@ -43,10 +43,10 @@
|
|||
<Carte titre={t('import.rapport.titre')} niveau={3}>
|
||||
<div class="contenu">
|
||||
{#if rapport.libelle === null}
|
||||
<Message bloc="rienEcrit"><p>{t('import.rapport.rienEcrit')}</p></Message>
|
||||
<Message bloc="rienEcrit"><p>{t(RIEN_ECRIT.get(rapport.nature))}</p></Message>
|
||||
{/if}
|
||||
<ul class="comptes">
|
||||
{#each COMPTES as [nom, cle] (nom)}
|
||||
{#each comptes as { nom, cle } (nom)}
|
||||
<li data-compte={nom}>
|
||||
<span class="nombre" data-nombre>{format.nombre(rapport.resume[nom])}</span>
|
||||
<span>{t(cle, { n: rapport.resume[nom] })}</span>
|
||||
|
|
|
|||
|
|
@ -2,24 +2,32 @@
|
|||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La section de l'import des participants (§ 10.1, § 10.2, § 2.1 étape
|
||||
// 2). Un fichier choisi par le dialogue de la façade, un fichier déposé
|
||||
// sur la fenêtre, ou un texte que le collage de la grille remet par
|
||||
// ui.ouvrirImport, ouvre l'aperçu ; rien ne s'importe hors de lui.
|
||||
// L'aperçu se calcule par apercevoir contre les participants et les
|
||||
// tables de la charge — les étiquettes des objectifs donnent leur
|
||||
// orthographe à celles du fichier —, sous le mode choisi et le choix des
|
||||
// colonnes ambiguës : il n'écrit rien, et s'ouvre en lecture comme sur un
|
||||
// plan bloqué. Seule
|
||||
// « Importer » écrit, par seance.executer('importerParticipants', …), et
|
||||
// son refus se pose à côté d'elle ; « Remplacer » passe d'abord par la
|
||||
// fenêtre à confirmer, qui porte les quatre comptes de bilanRemplacement,
|
||||
// et ne l'ouvre que sur un import que la commande accepterait.
|
||||
// Le rapport lit le résumé que la séance rend.
|
||||
// La section d'un import, § 10.1, § 10.2, § 10.4 et § 2.1 étape 2 : des
|
||||
// participants, ou des objectifs de table, selon la nature que
|
||||
// l'opérateur coche avant l'aperçu — Participants par défaut. La nature
|
||||
// ne se devine jamais de l'en-tête : un fichier lu sous la mauvaise se
|
||||
// refuse, et l'opérateur la change. Un fichier choisi par le dialogue de
|
||||
// la façade, un fichier déposé sur la fenêtre, ou un texte que le collage
|
||||
// de la grille remet par ui.ouvrirImport, ouvre l'aperçu ; rien ne
|
||||
// s'importe hors de lui. Le collage vient de la grille des participants,
|
||||
// et coche leur nature.
|
||||
//
|
||||
// L'aperçu se calcule par apercevoirSelon (modele.js) contre la charge,
|
||||
// sous la nature, le mode choisi et le choix des colonnes ambiguës : il
|
||||
// n'écrit rien, et s'ouvre en lecture comme sur un plan bloqué. Changer
|
||||
// de nature recalcule l'aperçu du même texte, le choix des colonnes et le
|
||||
// mode repartant de rien. Seule « Importer » écrit, par seance.executer —
|
||||
// importerParticipants ou importerObjectifs —, et son refus se pose à
|
||||
// côté d'elle ; « Remplacer » passe d'abord par la fenêtre à confirmer,
|
||||
// qui porte les quatre comptes de bilanRemplacement pour des
|
||||
// participants, les deux de bilanRemplacementObjectifs — objectifs et
|
||||
// tables — pour des objectifs, et ne l'ouvre que sur un import que la
|
||||
// commande accepterait. Le rapport lit le résumé que la séance rend.
|
||||
//
|
||||
// La section est une carte titrée, dont l'en-tête porte la consigne et
|
||||
// « Choisir un fichier CSV… », son action principale ; l'aperçu, puis le
|
||||
// rapport, sont des cartes sous elle.
|
||||
// « Choisir un fichier CSV… », son action principale, et dont le contenu
|
||||
// est le choix de la nature ; l'aperçu, puis le rapport, sont des cartes
|
||||
// sous elle.
|
||||
//
|
||||
// Le CSV des refus — de l'aperçu, ou du résumé dans le rapport — vient
|
||||
// d'exporterRefus de src/application/csv.js, dont le motif est celui de
|
||||
|
|
@ -28,13 +36,27 @@
|
|||
// et corrige le fichier.
|
||||
import { getContext, untrack } from 'svelte';
|
||||
import { appliquerCommande } from '../../application/commandes.js';
|
||||
import { apercevoir, bilanRemplacement, decoder, exporterRefus } from '../../application/csv.js';
|
||||
import { bilanRemplacement, bilanRemplacementObjectifs, decoder, exporterRefus } from '../../application/csv.js';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Confirmation from '../primitives/Confirmation.svelte';
|
||||
import Apercu from './Apercu.svelte';
|
||||
import Rapport from './Rapport.svelte';
|
||||
import { MODES_D_IMPORT, avecEntetesDeLaGrille, modeleApercu, modePropose } from './modele.js';
|
||||
import {
|
||||
NATURES_D_IMPORT,
|
||||
NATURE_PAR_DEFAUT,
|
||||
apercevoirSelon,
|
||||
avecEntetesDeLaGrille,
|
||||
modeleApercu,
|
||||
modePropose,
|
||||
modesDImport,
|
||||
} from './modele.js';
|
||||
|
||||
// La commande qu'applique « Importer », par nature.
|
||||
const COMMANDES_DES_NATURES = new Map([
|
||||
['participants', 'importerParticipants'],
|
||||
['objectifs', 'importerObjectifs'],
|
||||
]);
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
|
|
@ -45,38 +67,55 @@
|
|||
// La source de l'aperçu : { origine: 'fichier'|'collage', nom, encodage,
|
||||
// texte, refusDecodage } ; texte null sous un refus de decoder.
|
||||
let source = $state.raw(null);
|
||||
// La nature cochée : ce que le fichier importe.
|
||||
let nature = $state(NATURE_PAR_DEFAUT);
|
||||
// { champ: rang } qui tranche une ambiguïté de l'en-tête.
|
||||
let choix = $state.raw({});
|
||||
let mode = $state('ajouter');
|
||||
// Le dernier import appliqué : { modele, source, apercu, resume, libelle }.
|
||||
// Le dernier import appliqué : { nature, modele, source, apercu, resume, libelle }.
|
||||
let rapport = $state.raw(null);
|
||||
// La fenêtre de « Remplacer » ouverte : { apercu, elements }.
|
||||
// La fenêtre de « Remplacer » ouverte : { titre, nature, apercu, depuis, elements }.
|
||||
let confirmation = $state.raw(null);
|
||||
// Le chemin où le CSV des refus vient d'être enregistré.
|
||||
let enregistre = $state(null);
|
||||
|
||||
const participants = $derived($seance.charge?.participants ?? []);
|
||||
const tables = $derived($seance.charge?.tables ?? []);
|
||||
const charge = $derived($seance.charge ?? null);
|
||||
const participants = $derived(charge?.participants ?? []);
|
||||
const apercu = $derived(
|
||||
source === null || source.refusDecodage !== null ? null : apercevoir(source.texte, { participants, tables, choix, mode }),
|
||||
source === null || source.refusDecodage !== null || charge === null
|
||||
? null
|
||||
: apercevoirSelon(nature, source.texte, charge, { choix, mode }),
|
||||
);
|
||||
const modele = $derived(
|
||||
source === null ? null : modeleApercu({ encodage: source.encodage, apercu, refusDecodage: source.refusDecodage }),
|
||||
source === null ? null : modeleApercu({ nature, encodage: source.encodage, apercu, refusDecodage: source.refusDecodage }),
|
||||
);
|
||||
// Le texte relu dans l'ordre de la grille, offert à un collage seul.
|
||||
const grille = $derived(source?.origine === 'collage' && apercu !== null ? avecEntetesDeLaGrille(source.texte, apercu) : null);
|
||||
const grille = $derived(
|
||||
source?.origine === 'collage' && apercu !== null ? avecEntetesDeLaGrille(source.texte, apercu, nature) : null,
|
||||
);
|
||||
|
||||
// Une source neuve : le choix des colonnes repart de rien, le mode
|
||||
// proposé suit la liste, le rapport précédent se retire.
|
||||
function ouvrirSource(nouvelle) {
|
||||
source = nouvelle;
|
||||
// Ce qui suit un aperçu neuf : le choix des colonnes repart de rien, le
|
||||
// mode proposé suit la nature et la liste, le rapport précédent se
|
||||
// retire.
|
||||
function repartir() {
|
||||
choix = {};
|
||||
mode = modePropose(untrack(() => participants));
|
||||
mode = modePropose(untrack(() => participants), untrack(() => nature));
|
||||
rapport = null;
|
||||
confirmation = null;
|
||||
enregistre = null;
|
||||
}
|
||||
|
||||
function ouvrirSource(nouvelle) {
|
||||
source = nouvelle;
|
||||
repartir();
|
||||
}
|
||||
|
||||
// Une autre nature : l'aperçu ouvert se recalcule sur le même texte.
|
||||
function choisirNature(nouvelle) {
|
||||
nature = nouvelle;
|
||||
repartir();
|
||||
}
|
||||
|
||||
// Les octets d'un fichier : leur texte décodé, ou le refus de decoder,
|
||||
// qui tient lieu d'aperçu. Toute autre erreur est une faute, qui remonte.
|
||||
function lireFichier({ nom, octets }) {
|
||||
|
|
@ -113,7 +152,9 @@
|
|||
if ($ui.importEnAttente === null) return;
|
||||
untrack(() => {
|
||||
const pris = ui.prendreImport();
|
||||
if (pris !== null) ouvrirSource({ origine: 'collage', nom: null, encodage: null, texte: pris.texte, refusDecodage: null });
|
||||
if (pris === null) return;
|
||||
nature = 'participants';
|
||||
ouvrirSource({ origine: 'collage', nom: null, encodage: null, texte: pris.texte, refusDecodage: null });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -131,12 +172,13 @@
|
|||
enregistre = null;
|
||||
}
|
||||
|
||||
// L'aperçu appliqué par la séance, dans son mode ; le rapport lit le
|
||||
// résumé rendu, et l'aperçu se ferme.
|
||||
async function appliquer(applique, sonMode, depuis) {
|
||||
const { libelle, resume } = await seance.executer('importerParticipants', { apercu: applique, mode: sonMode });
|
||||
// L'aperçu appliqué par la séance, par la commande de sa nature, dans son
|
||||
// mode ; le rapport lit le résumé rendu, et l'aperçu se ferme.
|
||||
async function appliquer(applique, sonMode, depuis, saNature) {
|
||||
const { libelle, resume } = await seance.executer(COMMANDES_DES_NATURES.get(saNature), { apercu: applique, mode: sonMode });
|
||||
rapport = {
|
||||
modele: modeleApercu({ encodage: depuis.encodage, apercu: applique }),
|
||||
nature: saNature,
|
||||
modele: modeleApercu({ nature: saNature, encodage: depuis.encodage, apercu: applique }),
|
||||
source: { origine: depuis.origine, nom: depuis.nom },
|
||||
apercu: applique,
|
||||
resume,
|
||||
|
|
@ -159,17 +201,32 @@
|
|||
const applique = apercu;
|
||||
const sonMode = mode;
|
||||
const depuis = source;
|
||||
const saNature = nature;
|
||||
if (sonMode !== 'remplacer') {
|
||||
await appliquer(applique, sonMode, depuis);
|
||||
await appliquer(applique, sonMode, depuis, saNature);
|
||||
return;
|
||||
}
|
||||
const { refus, charge } = seance.etat();
|
||||
const { refus, charge: courante } = seance.etat();
|
||||
if (refus !== null) throw refus;
|
||||
appliquerCommande('importerParticipants', charge, { apercu: applique, mode: sonMode });
|
||||
const bilan = bilanRemplacement(charge);
|
||||
confirmation = {
|
||||
apercu: applique,
|
||||
depuis,
|
||||
appliquerCommande(COMMANDES_DES_NATURES.get(saNature), courante, { apercu: applique, mode: sonMode });
|
||||
confirmation = { nature: saNature, apercu: applique, depuis, ...annonceDuRemplacement(saNature, courante) };
|
||||
}
|
||||
|
||||
// Le titre de la fenêtre de « Remplacer » et ce qu'elle nomme détruit :
|
||||
// les personnes, réservations, titres pourvus et propositions pour des
|
||||
// participants ; les objectifs et les tables qui en portent pour des
|
||||
// objectifs.
|
||||
function annonceDuRemplacement(saNature, courante) {
|
||||
if (saNature === 'objectifs') {
|
||||
const bilan = bilanRemplacementObjectifs(courante);
|
||||
return {
|
||||
titre: 'import.remplacer.titreObjectifs',
|
||||
elements: [t('import.remplacer.objectifs', { n: bilan.objectifs }), t('import.remplacer.tables', { n: bilan.tables })],
|
||||
};
|
||||
}
|
||||
const bilan = bilanRemplacement(courante);
|
||||
return {
|
||||
titre: 'import.remplacer.titre',
|
||||
elements: [
|
||||
t('import.remplacer.participants', { n: bilan.participants }),
|
||||
t('import.remplacer.reservations', { n: bilan.reservations }),
|
||||
|
|
@ -180,7 +237,7 @@
|
|||
}
|
||||
|
||||
async function confirmerRemplacement() {
|
||||
await appliquer(confirmation.apercu, 'remplacer', confirmation.depuis);
|
||||
await appliquer(confirmation.apercu, 'remplacer', confirmation.depuis, confirmation.nature);
|
||||
confirmation = null;
|
||||
}
|
||||
|
||||
|
|
@ -197,6 +254,21 @@
|
|||
{#snippet actions()}
|
||||
<Commande cle="import.commande.choisirFichier" nom="choisirFichier" variante="principale" executer={choisirFichier} />
|
||||
{/snippet}
|
||||
<fieldset class="natures" data-nature-import>
|
||||
<legend>{t('import.nature.titre')}</legend>
|
||||
{#each NATURES_D_IMPORT as { nature: valeur, cle } (valeur)}
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name={`${uid}-nature`}
|
||||
value={valeur}
|
||||
checked={nature === valeur}
|
||||
onchange={() => choisirNature(valeur)}
|
||||
/>
|
||||
<span>{t(cle)}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</fieldset>
|
||||
</Carte>
|
||||
|
||||
{#if source !== null}
|
||||
|
|
@ -207,7 +279,7 @@
|
|||
{#if modele.global === null}
|
||||
<fieldset class="modes" data-champ="mode">
|
||||
<legend>{t('import.mode.titre')}</legend>
|
||||
{#each MODES_D_IMPORT as { mode: valeur, cle } (valeur)}
|
||||
{#each modesDImport(nature) as { mode: valeur, cle } (valeur)}
|
||||
<label>
|
||||
<input type="radio" name={`${uid}-mode`} value={valeur} bind:group={mode} />
|
||||
<span>{t(cle)}</span>
|
||||
|
|
@ -265,7 +337,7 @@
|
|||
|
||||
{#if confirmation !== null}
|
||||
<Confirmation
|
||||
titre="import.remplacer.titre"
|
||||
titre={confirmation.titre}
|
||||
elements={confirmation.elements}
|
||||
surConfirmer={confirmerRemplacement}
|
||||
surAnnuler={() => (confirmation = null)}
|
||||
|
|
@ -297,7 +369,8 @@
|
|||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
.modes {
|
||||
.modes,
|
||||
.natures {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-3);
|
||||
|
|
@ -312,4 +385,11 @@
|
|||
padding: 0 var(--espace-1);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Le choix de la nature et celui du mode se font aussi au clavier : le
|
||||
bouton radio qui a le focus porte l'anneau de l'application. */
|
||||
input:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,39 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le modèle de l'aperçu d'import (§ 10.1, § 10.2) : ce que l'aperçu montre,
|
||||
// en clés de libellés et en données, d'un fichier décodé, d'un fichier que
|
||||
// decoder refuse, ou d'un collage. Le texte vient de la traduction ; ce
|
||||
// module ne choisit que les clés, et ne recalcule rien de ce que l'aperçu de
|
||||
// src/csv établit : association, lignes, fusions et doublons s'y lisent tels
|
||||
// quels.
|
||||
import { CHAMPS, JOINTURE_ETIQUETTES } from '../../application/csv.js';
|
||||
// Le modèle de l'aperçu d'un import, § 10.1, § 10.2 et § 10.4 : ce que
|
||||
// l'aperçu montre, en clés de libellés et en données, d'un fichier décodé,
|
||||
// d'un fichier que decoder refuse, ou d'un collage, sous la nature que
|
||||
// l'opérateur a choisie — des participants ou des objectifs de table. Le
|
||||
// texte vient de la traduction ; ce module ne choisit que les clés, et ne
|
||||
// recalcule rien de ce que l'aperçu de src/csv établit : association,
|
||||
// lignes, fusions et doublons s'y lisent tels quels.
|
||||
//
|
||||
// La nature se reçoit, jamais ne se déduit du texte : une liste de
|
||||
// participants porte souvent une colonne « Table » d'une soirée passée, et
|
||||
// un en-tête d'objectifs lu sous Participants reste refusé, jamais lu comme
|
||||
// des objectifs.
|
||||
import { CHAMPS, CHAMPS_OBJECTIFS, apercevoir, apercevoirObjectifs } from '../../application/csv.js';
|
||||
|
||||
/** Les deux natures d'un fichier importé (§ 10.4), dans l'ordre où l'écran
|
||||
* les offre, et la clé de leur nom. */
|
||||
export const NATURES_D_IMPORT = Object.freeze(
|
||||
[
|
||||
['participants', 'import.nature.participants'],
|
||||
['objectifs', 'import.nature.objectifs'],
|
||||
].map(([nature, cle]) => Object.freeze({ nature, cle })),
|
||||
);
|
||||
|
||||
/** La nature cochée à l'ouverture de la section : les participants. */
|
||||
export const NATURE_PAR_DEFAUT = 'participants';
|
||||
|
||||
// La nature reçue, ou RangeError qui la nomme : jamais une nature devinée.
|
||||
function exigerNature(nature) {
|
||||
if (!NATURES_D_IMPORT.some((offerte) => offerte.nature === nature)) {
|
||||
throw new RangeError(`import : nature inconnue ${JSON.stringify(nature)}`);
|
||||
}
|
||||
return nature;
|
||||
}
|
||||
|
||||
/** La clé du nom de chaque encodage que rend decoder, dans l'ordre de ses
|
||||
* quatre temps. */
|
||||
|
|
@ -28,7 +54,8 @@ export const SEPARATEURS = new Map([
|
|||
[null, 'import.separateur.uneColonne'],
|
||||
]);
|
||||
|
||||
// La clé du nom de chaque champ, dans l'ordre de CHAMPS.
|
||||
// La clé du nom de chaque champ, dans l'ordre de CHAMPS, puis de
|
||||
// CHAMPS_OBJECTIFS : aucun nom n'est commun aux deux.
|
||||
const CLES_DES_CHAMPS = new Map([
|
||||
['nom', 'import.champ.nom'],
|
||||
['prenom', 'import.champ.prenom'],
|
||||
|
|
@ -38,6 +65,18 @@ const CLES_DES_CHAMPS = new Map([
|
|||
['exclu', 'import.champ.exclu'],
|
||||
['notes', 'import.champ.notes'],
|
||||
['etiquettes', 'import.champ.etiquettes'],
|
||||
['table', 'import.champ.table'],
|
||||
['tour', 'import.champ.tour'],
|
||||
['objectif', 'import.champ.objectif'],
|
||||
['regle', 'import.champ.regle'],
|
||||
]);
|
||||
|
||||
// La clé de la règle lue d'un objectif ; null : l'objectif suit la règle par
|
||||
// défaut de l'événement.
|
||||
const CLES_DE_REGLE = new Map([
|
||||
['ou', 'import.regle.ou'],
|
||||
['et', 'import.regle.et'],
|
||||
[null, 'import.regle.defaut'],
|
||||
]);
|
||||
|
||||
// La clé de la valeur lue d'« exclu » ; une cellule vide n'en a pas.
|
||||
|
|
@ -49,15 +88,55 @@ const CLES_D_EXCLU = new Map([
|
|||
// Le nom de chaque champ dans ChampsLus, celui de l'aperçu de src/csv.
|
||||
const CHAMPS_LUS = new Map([['titre_pressenti', 'titrePressenti']]);
|
||||
|
||||
/** Les trois modes de l'import (§ 10.1), dans l'ordre où l'écran les
|
||||
* offre, et la clé de leur nom. */
|
||||
export const MODES_D_IMPORT = Object.freeze(
|
||||
[
|
||||
['ajouter', 'import.mode.ajouter'],
|
||||
['mettreAJour', 'import.mode.mettreAJour'],
|
||||
['remplacer', 'import.mode.remplacer'],
|
||||
].map(([mode, cle]) => Object.freeze({ mode, cle })),
|
||||
);
|
||||
// Des modes et la clé de leur nom, figés.
|
||||
const modes = (paires) => Object.freeze(paires.map(([mode, cle]) => Object.freeze({ mode, cle })));
|
||||
|
||||
/** Les trois modes de l'import des participants (§ 10.1), dans l'ordre où
|
||||
* l'écran les offre, et la clé de leur nom. */
|
||||
export const MODES_D_IMPORT = modes([
|
||||
['ajouter', 'import.mode.ajouter'],
|
||||
['mettreAJour', 'import.mode.mettreAJour'],
|
||||
['remplacer', 'import.mode.remplacer'],
|
||||
]);
|
||||
|
||||
// Les deux modes de l'import des objectifs (§ 10.4) : ajouter n'en est pas
|
||||
// un, un couple (table, tour) déjà pourvu y serait une collision.
|
||||
const MODES_D_IMPORT_OBJECTIFS = modes([
|
||||
['mettreAJour', 'import.mode.mettreAJour'],
|
||||
['remplacer', 'import.mode.remplacer'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Les modes qu'offre l'import d'une nature, dans l'ordre de l'écran.
|
||||
*
|
||||
* @param {'participants'|'objectifs'} nature
|
||||
* @returns {ReadonlyArray<{mode: string, cle: string}>}
|
||||
* @throws {RangeError} une nature inconnue
|
||||
*/
|
||||
export function modesDImport(nature) {
|
||||
return exigerNature(nature) === 'participants' ? MODES_D_IMPORT : MODES_D_IMPORT_OBJECTIFS;
|
||||
}
|
||||
|
||||
/**
|
||||
* L'aperçu d'un texte décodé sous la nature choisie, contre la charge : celui
|
||||
* des participants (apercevoir, contre ses personnes et ses tables, dont les
|
||||
* objectifs donnent leur orthographe aux étiquettes) ou celui des objectifs
|
||||
* de table (apercevoirObjectifs). Rien ne s'écrit.
|
||||
*
|
||||
* @param {'participants'|'objectifs'} nature
|
||||
* @param {string} texte
|
||||
* @param {Object} charge la charge de l'événement ouvert
|
||||
* @param {{choix?: Object<string, number>, mode?: string}} [options] le
|
||||
* choix des colonnes ambiguës et le mode ; chaque aperçu prend son mode
|
||||
* par défaut quand mode manque
|
||||
* @returns {Object} l'aperçu de la nature, tel que src/csv le rend
|
||||
* @throws {RangeError} une nature inconnue
|
||||
*/
|
||||
export function apercevoirSelon(nature, texte, charge, { choix = {}, mode } = {}) {
|
||||
const options = mode === undefined ? { choix } : { choix, mode };
|
||||
if (exigerNature(nature) === 'objectifs') return apercevoirObjectifs(texte, charge, options);
|
||||
return apercevoir(texte, { participants: charge.participants, tables: charge.tables, ...options });
|
||||
}
|
||||
|
||||
/** Les trois sortes d'un message de l'aperçu et du rapport, chacune un ton
|
||||
* de pastille (Pastille, sorte 'statut'). */
|
||||
|
|
@ -76,7 +155,8 @@ const ICONES_DE_SORTE = new Map([
|
|||
// que le logiciel ne fusionne jamais seul — doublons, homonymes d'une mise
|
||||
// à jour —, ou une cellule sur plusieurs lignes qui porte le séparateur,
|
||||
// peut-être un guillemet mal fermé ; une information dit ce qui a été fait
|
||||
// ou lu sans rien écarter.
|
||||
// ou lu sans rien écarter — les orthographes fondues d'appartenances et
|
||||
// celles d'étiquettes, deux blocs distincts.
|
||||
const SORTES_DES_BLOCS = new Map([
|
||||
['global', 'refus'],
|
||||
['refusees', 'refus'],
|
||||
|
|
@ -84,6 +164,7 @@ const SORTES_DES_BLOCS = new Map([
|
|||
['homonymes', 'avis'],
|
||||
['multilignes', 'avis'],
|
||||
['fusions', 'information'],
|
||||
['fusionsEtiquettes', 'information'],
|
||||
['rienEcrit', 'information'],
|
||||
]);
|
||||
|
||||
|
|
@ -91,7 +172,7 @@ const SORTES_DES_BLOCS = new Map([
|
|||
* La classe d'un bloc de messages de l'aperçu ou du rapport.
|
||||
*
|
||||
* @param {string} bloc 'global', 'refusees', 'doublons', 'homonymes',
|
||||
* 'fusions', 'multilignes' ou 'rienEcrit'
|
||||
* 'fusions', 'fusionsEtiquettes', 'multilignes' ou 'rienEcrit'
|
||||
* @returns {{sorte: string, cle: string, icone: string}} la sorte, la clé
|
||||
* de son libellé et l'icône de sa pastille
|
||||
* @throws {RangeError} un bloc inconnu
|
||||
|
|
@ -102,16 +183,61 @@ export function messageDuBloc(bloc) {
|
|||
return { sorte, cle: `import.sorte.${sorte}`, icone: ICONES_DE_SORTE.get(sorte) };
|
||||
}
|
||||
|
||||
/** Les colonnes du tableau des lignes : chaque champ, dans l'ordre de
|
||||
* CHAMPS, et la clé de son nom. */
|
||||
export const COLONNES_DES_LIGNES = Object.freeze(CHAMPS.map((champ) => Object.freeze({ champ, cle: CLES_DES_CHAMPS.get(champ) })));
|
||||
// Des champs et la clé de leur nom, figés.
|
||||
const colonnes = (champs) => Object.freeze(champs.map((champ) => Object.freeze({ champ, cle: CLES_DES_CHAMPS.get(champ) })));
|
||||
|
||||
/** Les colonnes du tableau des lignes des participants : chaque champ, dans
|
||||
* l'ordre de CHAMPS, et la clé de son nom. */
|
||||
export const COLONNES_DES_LIGNES = colonnes(CHAMPS);
|
||||
|
||||
/** Les colonnes du tableau des lignes des objectifs : chaque champ, dans
|
||||
* l'ordre de CHAMPS_OBJECTIFS, et la clé de son nom. */
|
||||
export const COLONNES_DES_OBJECTIFS = colonnes(CHAMPS_OBJECTIFS);
|
||||
|
||||
// Les comptes du rapport de chaque nature, dans l'ordre du résumé de son
|
||||
// import, et la clé de leur libellé, accordé au nombre.
|
||||
const COMPTES_DU_RAPPORT = new Map([
|
||||
[
|
||||
'participants',
|
||||
[
|
||||
['ajoutes', 'import.rapport.ajoutes'],
|
||||
['misAJour', 'import.rapport.misAJour'],
|
||||
['inchanges', 'import.rapport.inchanges'],
|
||||
['retires', 'import.rapport.retires'],
|
||||
['reservationsRetirees', 'import.rapport.reservationsRetirees'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'objectifs',
|
||||
[
|
||||
['ajoutes', 'import.rapport.objectifsAjoutes'],
|
||||
['remplaces', 'import.rapport.objectifsRemplaces'],
|
||||
['inchanges', 'import.rapport.objectifsInchanges'],
|
||||
['retires', 'import.rapport.objectifsRetires'],
|
||||
['tables', 'import.rapport.tablesTouchees'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Les comptes que montre le rapport d'un import : chaque nom du résumé de
|
||||
* la commande de la nature, et la clé de son libellé.
|
||||
*
|
||||
* @param {'participants'|'objectifs'} nature
|
||||
* @returns {Array<{nom: string, cle: string}>}
|
||||
* @throws {RangeError} une nature inconnue
|
||||
*/
|
||||
export function comptesDuRapport(nature) {
|
||||
return COMPTES_DU_RAPPORT.get(exigerNature(nature)).map(([nom, cle]) => ({ nom, cle }));
|
||||
}
|
||||
|
||||
// Le remède d'un refus de decoder : le code que ses détails portent, que le
|
||||
// texte du refus compose déjà (contrat « Refus et avertissements »).
|
||||
const REFUS_DE_DECODER = new Set(['UTF16_SANS_MARQUE', 'UTF16_INVALIDE', 'UTF8_INVALIDE', 'CARACTERE_NUL']);
|
||||
|
||||
// La clé du remède de chaque refus global de l'aperçu. Un caractère nul n'y
|
||||
// arrive que d'un texte collé : celui d'un fichier, decoder le refuse.
|
||||
// La clé du remède de chaque refus global de l'aperçu des participants. Un
|
||||
// caractère nul n'y arrive que d'un texte collé : celui d'un fichier,
|
||||
// decoder le refuse.
|
||||
const REMEDES_DE_L_APERCU = new Map([
|
||||
['CARACTERE_NUL', 'import.remede.caractereNulCollage'],
|
||||
['AUCUN_ENTETE', 'import.remede.aucunEntete'],
|
||||
|
|
@ -121,6 +247,26 @@ const REMEDES_DE_L_APERCU = new Map([
|
|||
['SEPARATEUR_INTROUVABLE', 'import.remede.separateurIntrouvable'],
|
||||
]);
|
||||
|
||||
// La clé du remède de chaque refus global de l'aperçu des objectifs (§ 10.4) :
|
||||
// ceux du § 10.1 hors NOM_NON_ASSOCIE, et les trois qui lui sont propres.
|
||||
// OBJECTIFS_SANS_TABLE n'en a pas : son texte dit déjà de créer les tables,
|
||||
// et son remède est le geste qui ouvre leur section.
|
||||
const REMEDES_DES_OBJECTIFS = new Map([
|
||||
['CARACTERE_NUL', 'import.remede.caractereNulCollage'],
|
||||
['AUCUN_ENTETE', 'import.remede.aucunEnteteObjectifs'],
|
||||
['TABLE_NON_ASSOCIEE', 'import.remede.tableNonAssociee'],
|
||||
['OBJECTIF_NON_ASSOCIE', 'import.remede.objectifNonAssocie'],
|
||||
['OBJECTIFS_SANS_TABLE', null],
|
||||
['AUCUNE_LIGNE_VALIDE', 'import.remede.aucuneLigneValide'],
|
||||
['GUILLEMET_OUVERT', 'import.remede.guillemetOuvert'],
|
||||
['SEPARATEUR_INTROUVABLE', 'import.remede.separateurIntrouvable'],
|
||||
]);
|
||||
|
||||
// Le geste qui remédie à un refus global des objectifs, celui que la
|
||||
// commande importerObjectifs pose sur le même code : la section des tables
|
||||
// pour un événement sans table.
|
||||
const GESTES_DES_OBJECTIFS = new Map([['OBJECTIFS_SANS_TABLE', Object.freeze({ geste: 'ouvrirSection', section: 'tables' })]]);
|
||||
|
||||
// Le remède d'un collage auquel l'aperçu offre l'ordre de la grille.
|
||||
const REMEDES_DE_LA_GRILLE = new Map([
|
||||
['AUCUN_ENTETE', 'import.remede.aucunEnteteCollage'],
|
||||
|
|
@ -135,8 +281,10 @@ function dans(table, valeur, quoi) {
|
|||
}
|
||||
|
||||
// Vrai quand l'aperçu offre la lecture dans l'ordre de la grille : un texte
|
||||
// non blanc sans en-tête reconnu, ou une seule colonne sans séparateur.
|
||||
function offreLaGrille(apercu) {
|
||||
// non blanc sans en-tête reconnu, ou une seule colonne sans séparateur, lu
|
||||
// comme une liste de participants. La grille n'a pas l'ordre des objectifs.
|
||||
function offreLaGrille(apercu, nature = 'participants') {
|
||||
if (nature !== 'participants') return false;
|
||||
if (apercu.refusGlobal === 'AUCUN_ENTETE') return apercu.entetes.length > 0;
|
||||
return apercu.refusGlobal === 'SEPARATEUR_INTROUVABLE' && apercu.refusGlobalLigne === null;
|
||||
}
|
||||
|
|
@ -145,18 +293,24 @@ function offreLaGrille(apercu) {
|
|||
// le code de details.remede, déjà dans la cause.
|
||||
function globalDeDecoder({ code, details }) {
|
||||
if (!REFUS_DE_DECODER.has(code)) throw new RangeError(`import : refus de decoder inconnu ${JSON.stringify(code)}`);
|
||||
return { cle: code, details, remede: { cle: details.remede, details: {}, dansLaCause: true } };
|
||||
return { cle: code, details, remede: { cle: details.remede, details: {}, dansLaCause: true, geste: null } };
|
||||
}
|
||||
|
||||
// Le refus global d'un aperçu, null sans refus. Un collage auquel l'aperçu
|
||||
// offre l'ordre de la grille prend le remède qui le nomme.
|
||||
function globalDeLApercu(apercu, collage) {
|
||||
// offre l'ordre de la grille prend le remède qui le nomme. Un refus que son
|
||||
// texte remédie déjà porte le seul geste qui y mène.
|
||||
function globalDeLApercu(apercu, collage, nature) {
|
||||
const code = apercu.refusGlobal;
|
||||
if (code === null) return null;
|
||||
const remede = collage && offreLaGrille(apercu) ? REMEDES_DE_LA_GRILLE.get(code) : dans(REMEDES_DE_L_APERCU, code, 'refus global');
|
||||
const rang = apercu.refusGlobalLigne;
|
||||
const details = rang === null ? {} : { ligne: rang };
|
||||
return { cle: code, details, remede: { cle: remede, details, dansLaCause: false } };
|
||||
if (nature === 'objectifs') {
|
||||
const remede = dans(REMEDES_DES_OBJECTIFS, code, 'refus global des objectifs');
|
||||
const geste = GESTES_DES_OBJECTIFS.get(code) ?? null;
|
||||
return { cle: code, details, remede: { cle: remede, details, dansLaCause: remede === null, geste } };
|
||||
}
|
||||
const remede = collage && offreLaGrille(apercu) ? REMEDES_DE_LA_GRILLE.get(code) : dans(REMEDES_DE_L_APERCU, code, 'refus global');
|
||||
return { cle: code, details, remede: { cle: remede, details, dansLaCause: false, geste: null } };
|
||||
}
|
||||
|
||||
// Le nom du séparateur, quand la lecture l'a établi : rien sous un caractère
|
||||
|
|
@ -171,10 +325,10 @@ function separateurDe(apercu) {
|
|||
}
|
||||
|
||||
// Chaque colonne de l'en-tête : son rang, son en-tête tel qu'écrit, et la
|
||||
// clé de ce qu'elle devient — un champ, une colonne à trancher, une colonne
|
||||
// écartée par le choix d'une autre, une colonne non reconnue.
|
||||
function colonnesDe({ entetes, association }) {
|
||||
const champDuRang = new Map(CHAMPS.filter((champ) => Object.hasOwn(association.colonnes, champ)).map((champ) => [association.colonnes[champ], champ]));
|
||||
// clé de ce qu'elle devient — un champ de la nature, une colonne à trancher,
|
||||
// une colonne écartée par le choix d'une autre, une colonne non reconnue.
|
||||
function colonnesDe({ entetes, association }, champs) {
|
||||
const champDuRang = new Map(champs.filter((champ) => Object.hasOwn(association.colonnes, champ)).map((champ) => [association.colonnes[champ], champ]));
|
||||
const aTrancher = new Set(association.ambigus.flatMap(({ rangs }) => rangs));
|
||||
const nonReconnues = new Set(association.nonReconnues);
|
||||
return entetes.map((entete, rang) => {
|
||||
|
|
@ -186,21 +340,40 @@ function colonnesDe({ entetes, association }) {
|
|||
});
|
||||
}
|
||||
|
||||
// Chaque ligne valide : son rang, puis une cellule par champ, dans l'ordre
|
||||
// de CHAMPS : le texte lu, ou la clé de la valeur d'« exclu » ; les
|
||||
// étiquettes jointes comme l'export les joint, null sans étiquette.
|
||||
function lignesDe(lignes) {
|
||||
// Une cellule de l'aperçu : un texte lu, une clé de libellé, ou une liste
|
||||
// d'étiquettes, chacune une pastille ; les deux autres null.
|
||||
const cellule = (champ, { texte = null, cle = null, etiquettes = null }) => ({ champ, texte, cle, etiquettes });
|
||||
|
||||
// Chaque ligne valide des participants : son rang, puis une cellule par
|
||||
// champ, dans l'ordre de CHAMPS : le texte lu, ou la clé de la valeur
|
||||
// d'« exclu » ; les étiquettes une à une, aucune pour une cellule vide.
|
||||
function lignesDesParticipants(lignes) {
|
||||
return lignes.map(({ ligne, champs }) => ({
|
||||
ligne,
|
||||
cellules: CHAMPS.map((champ) => {
|
||||
const valeur = champs[CHAMPS_LUS.get(champ) ?? champ];
|
||||
if (champ === 'exclu') return { champ, texte: null, cle: valeur === null ? null : CLES_D_EXCLU.get(valeur) };
|
||||
if (champ === 'etiquettes') return { champ, texte: valeur === null ? null : valeur.join(JOINTURE_ETIQUETTES), cle: null };
|
||||
return { champ, texte: valeur, cle: null };
|
||||
if (champ === 'exclu') return cellule(champ, { cle: valeur === null ? null : CLES_D_EXCLU.get(valeur) });
|
||||
if (champ === 'etiquettes') return cellule(champ, { etiquettes: valeur ?? [] });
|
||||
return cellule(champ, { texte: valeur });
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
// Chaque ligne valide des objectifs : son rang, puis le numéro affiché de la
|
||||
// table, le tour — la clé de la soirée pour un tour null —, les étiquettes
|
||||
// de l'objectif une à une, et la clé de sa règle.
|
||||
function lignesDesObjectifs(lignes) {
|
||||
return lignes.map(({ ligne, champs }) => ({
|
||||
ligne,
|
||||
cellules: [
|
||||
cellule('table', { texte: String(champs.numero) }),
|
||||
champs.tour === null ? cellule('tour', { cle: 'import.tour.soiree' }) : cellule('tour', { texte: String(champs.tour) }),
|
||||
cellule('objectif', { etiquettes: champs.etiquettes }),
|
||||
cellule('regle', { cle: CLES_DE_REGLE.get(champs.regle) }),
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Les lignes refusées, par l'aperçu ou par la mise à jour, telles que
|
||||
* l'aperçu et le rapport les montrent : le rang, le code, et les détails que
|
||||
|
|
@ -231,14 +404,17 @@ export function nommer(participants, ids) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Le mode d'import proposé (§ 10.1) : Ajouter pour un événement sans
|
||||
* participant, Mettre à jour sinon. Remplacer ne se propose jamais : il se
|
||||
* choisit.
|
||||
* Le mode d'import proposé (§ 10.1, § 10.4) : pour des participants, Ajouter
|
||||
* dans un événement sans participant, Mettre à jour sinon ; pour des
|
||||
* objectifs, Mettre à jour. Remplacer ne se propose jamais : il se choisit.
|
||||
*
|
||||
* @param {Array<Object>} participants
|
||||
* @param {'participants'|'objectifs'} [nature] participants par défaut
|
||||
* @returns {'ajouter'|'mettreAJour'}
|
||||
* @throws {RangeError} une nature inconnue
|
||||
*/
|
||||
export function modePropose(participants) {
|
||||
export function modePropose(participants, nature = 'participants') {
|
||||
if (exigerNature(nature) === 'objectifs') return 'mettreAJour';
|
||||
return participants.length === 0 ? 'ajouter' : 'mettreAJour';
|
||||
}
|
||||
|
||||
|
|
@ -252,6 +428,7 @@ const RIEN = Object.freeze({
|
|||
lignes: [],
|
||||
refusees: [],
|
||||
fusions: [],
|
||||
fusionsEtiquettes: [],
|
||||
doublons: [],
|
||||
homonymes: [],
|
||||
multilignes: [],
|
||||
|
|
@ -259,83 +436,107 @@ const RIEN = Object.freeze({
|
|||
});
|
||||
|
||||
/**
|
||||
* Ce que l'aperçu montre, d'un fichier ou d'un collage : encodage (null pour
|
||||
* un collage) et séparateur nommés — « une seule colonne » pour un
|
||||
* séparateur null —, association par colonne, ambiguïtés, lignes valides et
|
||||
* refusées, fusions, doublons, et le refus global, de decoder (refusDecodage,
|
||||
* apercu null) ou de l'aperçu (apercu.refusGlobal), avec la clé de sa cause
|
||||
* et celle de son remède — en clés de libellés et en données.
|
||||
* Ce que l'aperçu montre, d'un fichier ou d'un collage, sous sa nature :
|
||||
* encodage (null pour un collage) et séparateur nommés — « une seule
|
||||
* colonne » pour un séparateur null —, association par colonne, ambiguïtés,
|
||||
* colonnes et lignes valides, lignes refusées, orthographes fondues
|
||||
* d'appartenances et d'étiquettes, chacune dans son bloc, doublons, et le
|
||||
* refus global, de decoder (refusDecodage, apercu null) ou de l'aperçu
|
||||
* (apercu.refusGlobal), avec la clé de sa cause et celle de son remède — en
|
||||
* clés de libellés et en données. Un aperçu des objectifs n'a ni
|
||||
* appartenances, ni doublons, ni homonymes : ces listes y sont vides.
|
||||
*
|
||||
* La cause est le code du refus, sa propre clé ; ses détails sont ceux de
|
||||
* decoder, ou { ligne } quand l'aperçu nomme un rang. Le remède d'un refus
|
||||
* de decoder est le code de details.remede, que le texte de la cause compose
|
||||
* déjà : remede.dansLaCause le dit, et l'écran ne le répète pas.
|
||||
* déjà : remede.dansLaCause le dit, et l'écran ne le répète pas. remede.geste
|
||||
* est le remède du contrat (commande.js) qui y mène, null sans geste.
|
||||
*
|
||||
* @param {{encodage?: string|null, apercu?: Object|null, refusDecodage?: {code: string, details: Object}|null}} entree
|
||||
* @param {{nature?: 'participants'|'objectifs', encodage?: string|null, apercu?: Object|null,
|
||||
* refusDecodage?: {code: string, details: Object}|null}} entree
|
||||
* nature : celle que l'opérateur a choisie, participants par défaut ;
|
||||
* encodage : celui que rend decoder, null pour un collage
|
||||
* @returns {{
|
||||
* encodage: string|null, separateur: string|null,
|
||||
* champs: ReadonlyArray<{champ: string, cle: string}>,
|
||||
* colonnes: Array<{rang: number, entete: string, cle: string}>,
|
||||
* ambigus: Array<{champ: string, cle: string, rangs: Array<{rang: number, entete: string}>}>,
|
||||
* valides: number,
|
||||
* lignes: Array<{ligne: number, cellules: Array<{champ: string, texte: string|null, cle: string|null}>}>,
|
||||
* lignes: Array<{ligne: number, cellules: Array<{champ: string, texte: string|null, cle: string|null, etiquettes: string[]|null}>}>,
|
||||
* refusees: Array<{ligne: number, code: string, details: Object}>,
|
||||
* fusions: Array<{affichee: string, orthographes: Array<{texte: string, lignes: number}>}>,
|
||||
* fusionsEtiquettes: Array<{retenue: string, orthographes: string[], lignes: number}>,
|
||||
* doublons: Array<{lignes: number[], existants: number[]}>,
|
||||
* homonymes: Array<{ligne: number, participants: number[]}>,
|
||||
* multilignes: Array<{ligne: number, rangs: number[]}>,
|
||||
* global: null|{cle: string, details: Object, remede: {cle: string, details: Object, dansLaCause: boolean}},
|
||||
* global: null|{cle: string, details: Object,
|
||||
* remede: {cle: string|null, details: Object, dansLaCause: boolean, geste: Object|null}},
|
||||
* }}
|
||||
* encodage, separateur, colonnes[].cle et ambigus[].cle sont des clés de
|
||||
* traduction, comme lignes[].cellules[].cle, celle de la valeur
|
||||
* d'« exclu » ; refusees[].details, ceux que reçoit le motif d'une ligne
|
||||
* (motifLigne) ; fusions[].orthographes[].lignes, le nombre de lignes de
|
||||
* l'orthographe, 0 pour celle que seuls des participants existants portent
|
||||
* @throws {RangeError} un encodage, un séparateur ou un refus sans clé
|
||||
* champs : les colonnes du tableau des lignes de la nature ; encodage,
|
||||
* separateur, colonnes[].cle et ambigus[].cle sont des clés de
|
||||
* traduction, comme lignes[].cellules[].cle — la valeur d'« exclu », la
|
||||
* soirée, la règle d'un objectif ; refusees[].details, ceux que reçoit le
|
||||
* motif d'une ligne (motifLigne) ; fusions[].orthographes[].lignes, le
|
||||
* nombre de lignes de l'orthographe, 0 pour celle que seuls des
|
||||
* participants existants portent ; fusionsEtiquettes[].lignes, le nombre
|
||||
* de lignes réécrites sous l'orthographe retenue
|
||||
* @throws {RangeError} une nature, un encodage, un séparateur ou un refus
|
||||
* sans clé
|
||||
*/
|
||||
export function modeleApercu({ encodage = null, apercu = null, refusDecodage = null }) {
|
||||
if (refusDecodage !== null) return { ...RIEN, global: globalDeDecoder(refusDecodage) };
|
||||
if (apercu === null) return { ...RIEN };
|
||||
export function modeleApercu({ nature = 'participants', encodage = null, apercu = null, refusDecodage = null }) {
|
||||
const objectifs = exigerNature(nature) === 'objectifs';
|
||||
const champs = objectifs ? COLONNES_DES_OBJECTIFS : COLONNES_DES_LIGNES;
|
||||
if (refusDecodage !== null) return { ...RIEN, champs, global: globalDeDecoder(refusDecodage) };
|
||||
if (apercu === null) return { ...RIEN, champs };
|
||||
const { entetes, association } = apercu;
|
||||
return {
|
||||
encodage: encodage === null ? null : dans(ENCODAGES, encodage, 'encodage'),
|
||||
separateur: separateurDe(apercu),
|
||||
colonnes: colonnesDe(apercu),
|
||||
champs,
|
||||
colonnes: colonnesDe(apercu, objectifs ? CHAMPS_OBJECTIFS : CHAMPS),
|
||||
ambigus: association.ambigus.map(({ champ, rangs }) => ({
|
||||
champ,
|
||||
cle: CLES_DES_CHAMPS.get(champ),
|
||||
rangs: rangs.map((rang) => ({ rang, entete: entetes[rang] })),
|
||||
})),
|
||||
valides: apercu.lignes.length,
|
||||
lignes: lignesDe(apercu.lignes),
|
||||
lignes: objectifs ? lignesDesObjectifs(apercu.lignes) : lignesDesParticipants(apercu.lignes),
|
||||
refusees: refuseesDe(apercu.refusees),
|
||||
fusions: apercu.fusions.map(({ affichee, orthographes }) => ({
|
||||
fusions: (apercu.fusions ?? []).map(({ affichee, orthographes }) => ({
|
||||
affichee,
|
||||
orthographes: orthographes.map(({ texte, lignes }) => ({ texte, lignes: lignes.length })),
|
||||
})),
|
||||
doublons: apercu.doublons.map(({ lignes, existants }) => ({ lignes, existants })),
|
||||
homonymes: apercu.homonymes,
|
||||
fusionsEtiquettes: apercu.fusionsEtiquettes.map(({ retenue, orthographes, lignes }) => ({
|
||||
retenue,
|
||||
orthographes: [...orthographes],
|
||||
lignes,
|
||||
})),
|
||||
doublons: (apercu.doublons ?? []).map(({ lignes, existants }) => ({ lignes, existants })),
|
||||
homonymes: apercu.homonymes ?? [],
|
||||
multilignes: apercu.multilignes,
|
||||
global: globalDeLApercu(apercu, encodage === null),
|
||||
global: globalDeLApercu(apercu, encodage === null, nature),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Le texte relu dans l'ordre de la grille : précédé d'une ligne d'en-têtes —
|
||||
* Le texte relu dans l'ordre de la grille, sous la nature des participants
|
||||
* seule : précédé d'une ligne d'en-têtes —
|
||||
* les largeur premiers de CHAMPS, puis des en-têtes vides au-delà de huit —,
|
||||
* joints par le séparateur de l'aperçu. Sous AUCUN_ENTETE d'un texte non
|
||||
* blanc, largeur vaut apercu.entetes.length ; sous SEPARATEUR_INTROUVABLE
|
||||
* sans candidat (refusGlobalLigne null : une seule colonne), largeur vaut 1
|
||||
* et la ligne est « nom ». null sous tout autre aperçu. Offert pour un
|
||||
* collage, jamais appliqué seul : le texte rendu repasse par apercevoir, et
|
||||
* les rangs de ce second aperçu comptent la ligne ajoutée.
|
||||
* les rangs de ce second aperçu comptent la ligne ajoutée. null sous la
|
||||
* nature des objectifs : la grille n'en a pas l'ordre.
|
||||
*
|
||||
* @param {string} texte le texte de l'aperçu
|
||||
* @param {Object} apercu l'aperçu de ce texte
|
||||
* @param {'participants'|'objectifs'} [nature] participants par défaut
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function avecEntetesDeLaGrille(texte, apercu) {
|
||||
if (!offreLaGrille(apercu)) return null;
|
||||
export function avecEntetesDeLaGrille(texte, apercu, nature = 'participants') {
|
||||
if (!offreLaGrille(apercu, exigerNature(nature))) return null;
|
||||
const largeur = apercu.refusGlobal === 'AUCUN_ENTETE' ? apercu.entetes.length : 1;
|
||||
const entetes = Array.from({ length: largeur }, (_, rang) => CHAMPS[rang] ?? '');
|
||||
return `${entetes.join(apercu.separateur ?? '')}\n${texte}`;
|
||||
|
|
|
|||
|
|
@ -1,27 +1,35 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le modèle de l'aperçu d'import (§ 10.1, § 10.2) : la lecture d'un collage
|
||||
// sans en-tête dans l'ordre de la grille, la clé de la cause et celle du
|
||||
// remède de chaque refus global, l'encodage et le séparateur nommés, et les
|
||||
// titres que « Remplacer » annonce, comptés par la règle unique des titres
|
||||
// pourvus. Les noms d'épreuve sont inventés.
|
||||
// Le modèle de l'aperçu d'un import, § 10.1, § 10.2 et § 10.4 : la lecture
|
||||
// d'un collage sans en-tête dans l'ordre de la grille, la clé de la cause et
|
||||
// celle du remède de chaque refus global, l'encodage et le séparateur
|
||||
// nommés, les titres que « Remplacer » annonce, comptés par la règle unique
|
||||
// des titres pourvus, la nature du fichier reçue et jamais devinée, les
|
||||
// objectifs de table et les étiquettes fondues, à part des appartenances.
|
||||
// Les noms d'épreuve sont inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { apercevoir, bilanRemplacement, CHAMPS, decoder } from '../../application/csv.js';
|
||||
import { apercevoir, apercevoirObjectifs, bilanRemplacement, CHAMPS, CHAMPS_OBJECTIFS, decoder } from '../../application/csv.js';
|
||||
import { comptesTitres, titresNonPourvus } from '../../application/listes_travail.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { creerCharge } from '../../stockage/document.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import {
|
||||
COLONNES_DES_LIGNES,
|
||||
COLONNES_DES_OBJECTIFS,
|
||||
ENCODAGES,
|
||||
MODES_D_IMPORT,
|
||||
NATURES_D_IMPORT,
|
||||
NATURE_PAR_DEFAUT,
|
||||
SEPARATEURS,
|
||||
SORTES_DE_MESSAGE,
|
||||
apercevoirSelon,
|
||||
avecEntetesDeLaGrille,
|
||||
comptesDuRapport,
|
||||
messageDuBloc,
|
||||
modesDImport,
|
||||
modeleApercu,
|
||||
modePropose,
|
||||
motifLigne,
|
||||
|
|
@ -315,7 +323,7 @@ describe('modeleApercu : ce que l’aperçu montre (§ 10.1)', () => {
|
|||
});
|
||||
|
||||
describe('les lignes, les refus de la mise à jour, les personnes désignées, le mode proposé (§ 10.1)', () => {
|
||||
test('chaque ligne valide montre ses huit champs dans l’ordre de CHAMPS ; exclu par sa clé, vide sans valeur ; les étiquettes jointes par « , »', () => {
|
||||
test('chaque ligne valide montre ses huit champs dans l’ordre de CHAMPS ; exclu par sa clé, vide sans valeur ; les étiquettes une à une, chacune sa pastille', () => {
|
||||
const texte = lignes(
|
||||
'notes;exclu;nom;prenom;mots-clés',
|
||||
'arrive tard;Oui;Ombrelle;Iris;',
|
||||
|
|
@ -330,7 +338,9 @@ describe('les lignes, les refus de la mise à jour, les personnes désignées, l
|
|||
for (const { cle } of COLONNES_DES_LIGNES) assert.ok(t(cle).length > 0, cle);
|
||||
const [premiere, deuxieme, troisieme] = lues;
|
||||
assert.deepEqual(premiere.cellules.map(({ texte: valeur }) => valeur), ['Ombrelle', 'Iris', null, null, null, null, 'arrive tard', null]);
|
||||
assert.deepEqual(deuxieme.cellules.at(-1), { champ: 'etiquettes', texte: 'finance, Montréal', cle: null });
|
||||
assert.deepEqual(deuxieme.cellules.at(-1), { champ: 'etiquettes', texte: null, cle: null, etiquettes: ['finance', 'Montréal'] });
|
||||
assert.deepEqual(premiere.cellules.at(-1), { champ: 'etiquettes', texte: null, cle: null, etiquettes: [] });
|
||||
for (const { champ, etiquettes } of premiere.cellules.slice(0, -1)) assert.equal(etiquettes, null, champ);
|
||||
assert.equal(premiere.cellules[5].cle, 'import.exclu.oui');
|
||||
assert.equal(deuxieme.cellules[5].cle, null);
|
||||
assert.equal(troisieme.cellules[5].cle, 'import.exclu.non');
|
||||
|
|
@ -426,8 +436,8 @@ describe('messageDuBloc : chaque message de l’aperçu et du rapport classé en
|
|||
}
|
||||
});
|
||||
|
||||
test('les informations : les orthographes fondues, un import qui n’a rien écrit', () => {
|
||||
for (const bloc of ['fusions', 'rienEcrit']) {
|
||||
test('les informations : les orthographes fondues, d’appartenance ou d’étiquette, un import qui n’a rien écrit', () => {
|
||||
for (const bloc of ['fusions', 'fusionsEtiquettes', 'rienEcrit']) {
|
||||
assert.deepEqual(messageDuBloc(bloc), { sorte: 'information', cle: 'import.sorte.information', icone: 'information' }, bloc);
|
||||
}
|
||||
});
|
||||
|
|
@ -438,7 +448,7 @@ describe('messageDuBloc : chaque message de l’aperçu et du rapport classé en
|
|||
|
||||
test('trois sortes, chacune un ton de pastille, un libellé traduit et une icône distincte', () => {
|
||||
assert.deepEqual(SORTES_DE_MESSAGE, ['refus', 'avis', 'information']);
|
||||
const blocs = ['global', 'refusees', 'doublons', 'homonymes', 'fusions', 'multilignes', 'rienEcrit'].map(messageDuBloc);
|
||||
const blocs = ['global', 'refusees', 'doublons', 'homonymes', 'fusions', 'fusionsEtiquettes', 'multilignes', 'rienEcrit'].map(messageDuBloc);
|
||||
assert.equal(new Set(blocs.map(({ icone }) => icone)).size, 3);
|
||||
assert.deepEqual(
|
||||
SORTES_DE_MESSAGE.map((sorte) => t(`import.sorte.${sorte}`)),
|
||||
|
|
@ -446,3 +456,294 @@ describe('messageDuBloc : chaque message de l’aperçu et du rapport classé en
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- La nature du fichier, les objectifs de table, les étiquettes (§ 10.1, § 10.4) ---
|
||||
|
||||
// Une charge de deux tours et de neuf tables, numérotées 1, 2, 3, 3, puis 4
|
||||
// à 8 : deux tables portent le numéro 3. Les personnes données, sans
|
||||
// étiquette par défaut.
|
||||
function chargeDesObjectifs({ participants = [], tables = null } = {}) {
|
||||
const charge = creerCharge({ id: 'epreuve-objectifs', nom: 'Soirée des Merles', siegesParDefaut: 6, tours: 2 });
|
||||
const numeros = [1, 2, 3, 3, 4, 5, 6, 7, 8];
|
||||
charge.tables = tables ?? numeros.map((numero, rang) => ({ id: rang + 1, numero, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } }));
|
||||
charge.participants = participants;
|
||||
charge.prochainsIds = { participant: participants.length + 1, table: charge.tables.length + 1, proposition: 1 };
|
||||
return charge;
|
||||
}
|
||||
|
||||
// Une personne d'épreuve, ses étiquettes données.
|
||||
const porteuse = (id, nom, etiquettes) => ({
|
||||
id, nom, prenom: null, appartenance: null, courriel: null, titrePressenti: null, notes: null, exclu: false, etiquettes,
|
||||
});
|
||||
|
||||
// Le CSV des objectifs qui refuse une ligne pour chaque motif du § 10.4,
|
||||
// dans l'ordre du texte : [ligne, code]. Huit lignes valides de remplissage
|
||||
// précèdent le champ en trop, que l'analyse du séparateur ne lit qu'au-delà
|
||||
// de ses vingt premiers enregistrements. La ligne 15 écrit « FINANCE », que
|
||||
// la réconciliation fond dans « finance ».
|
||||
const VINGT_ET_UNE = Array.from({ length: 21 }, (_, rang) => `mot${rang + 1}`).join('|');
|
||||
const TEXTE_DES_MOTIFS = lignes(
|
||||
'table;tour;objectif;regle',
|
||||
'1;;finance;',
|
||||
';;finance;',
|
||||
'un;;finance;',
|
||||
'9;;finance;',
|
||||
'3;;finance;',
|
||||
'2;2.0;finance;',
|
||||
'2;5;finance;',
|
||||
'2;1;;',
|
||||
`2;1;${'x'.repeat(41)};`,
|
||||
`2;1;${VINGT_ET_UNE};`,
|
||||
'2;1;finance;peut-être',
|
||||
'1;;Finance;et',
|
||||
'2;2;FINANCE;toutes',
|
||||
'4;;bénévole;ou',
|
||||
'4;1;bénévole;',
|
||||
'4;2;bénévole;',
|
||||
'5;;bénévole;',
|
||||
'5;1;bénévole;',
|
||||
'5;2;bénévole;',
|
||||
'6;;bénévole;',
|
||||
'6;1;bénévole;une',
|
||||
'1;1;finance;;en trop',
|
||||
);
|
||||
const MOTIFS_ATTENDUS = [
|
||||
[3, 'TABLE_ABSENTE'],
|
||||
[4, 'TABLE_NON_ENTIERE'],
|
||||
[5, 'NUMERO_TABLE_INCONNU'],
|
||||
[6, 'PLUSIEURS_TABLES'],
|
||||
[7, 'TOUR_NON_ENTIER'],
|
||||
[8, 'TOUR_HORS_SOIREE'],
|
||||
[9, 'OBJECTIF_VIDE'],
|
||||
[10, 'ETIQUETTE_MAL_FORMEE'],
|
||||
[11, 'ETIQUETTES_TROP_NOMBREUSES'],
|
||||
[12, 'REGLE_INCONNUE'],
|
||||
[13, 'OBJECTIF_EN_DOUBLE'],
|
||||
[23, 'CHAMPS_EN_TROP'],
|
||||
];
|
||||
|
||||
describe('la nature du fichier : choisie par l’opérateur, jamais devinée (§ 10.4)', () => {
|
||||
test('deux natures, Participants d’abord et par défaut, chacune nommée par sa clé', () => {
|
||||
assert.deepEqual(NATURES_D_IMPORT.map(({ nature }) => nature), ['participants', 'objectifs']);
|
||||
assert.equal(NATURE_PAR_DEFAUT, 'participants');
|
||||
assert.deepEqual(NATURES_D_IMPORT.map(({ cle }) => t(cle)), ['Participants', 'Objectifs de table']);
|
||||
});
|
||||
|
||||
test('l’en-tête d’un fichier d’objectifs lu sous Participants : l’aperçu des participants, refusé faute d’en-tête reconnu, jamais celui des objectifs', () => {
|
||||
const charge = chargeDesObjectifs();
|
||||
const texte = lignes('table;tour;objectif;regle', '1;;finance;');
|
||||
const lu = apercevoirSelon('participants', texte, charge);
|
||||
assert.equal(lu.refusGlobal, 'AUCUN_ENTETE');
|
||||
assert.deepEqual(lu, apercevoir(texte, { participants: charge.participants, tables: charge.tables }));
|
||||
const objectifs = apercevoirSelon('objectifs', texte, charge);
|
||||
assert.equal(objectifs.refusGlobal, null);
|
||||
assert.deepEqual(objectifs, apercevoirObjectifs(texte, charge));
|
||||
});
|
||||
|
||||
test('une liste de participants qui porte une colonne « Table » lue sous Objectifs : l’aperçu des objectifs, refusé faute d’objectif ; sous Participants, la table ignorée', () => {
|
||||
const charge = chargeDesObjectifs();
|
||||
const texte = lignes('nom;prenom;table', 'Ombrelle;Iris;2', 'Pervenche;Théo;1');
|
||||
assert.equal(apercevoirSelon('objectifs', texte, charge).refusGlobal, 'OBJECTIF_NON_ASSOCIE');
|
||||
const lu = apercevoirSelon('participants', texte, charge);
|
||||
assert.equal(lu.refusGlobal, null);
|
||||
assert.deepEqual(nomsLus(lu), ['Ombrelle', 'Pervenche']);
|
||||
assert.deepEqual(lu.association.nonReconnues, [2]);
|
||||
});
|
||||
|
||||
test('le choix des colonnes et le mode passent à l’aperçu de la nature ; une nature inconnue lève', () => {
|
||||
const charge = chargeDesObjectifs();
|
||||
const texte = lignes('table;objectif;objectifs', '1;finance;bénévole');
|
||||
const ambigu = apercevoirSelon('objectifs', texte, charge, { mode: 'remplacer' });
|
||||
assert.equal(ambigu.mode, 'remplacer');
|
||||
assert.deepEqual(ambigu.association.ambigus, [{ champ: 'objectif', rangs: [1, 2] }]);
|
||||
const tranche = apercevoirSelon('objectifs', texte, charge, { choix: { objectif: 2 }, mode: 'remplacer' });
|
||||
assert.deepEqual(tranche.lignes.map(({ champs }) => champs.etiquettes), [['bénévole']]);
|
||||
assert.equal(apercevoirSelon('participants', lignes('nom', 'Ombrelle'), charge, { mode: 'remplacer' }).mode, 'remplacer');
|
||||
assert.throws(() => apercevoirSelon('tables', texte, charge), { name: 'RangeError', message: /"tables"/ });
|
||||
});
|
||||
|
||||
test('les modes de chaque nature : trois pour les participants, Mettre à jour puis Remplacer pour les objectifs, que l’aperçu accepte', () => {
|
||||
assert.equal(modesDImport('participants'), MODES_D_IMPORT);
|
||||
assert.deepEqual(modesDImport('objectifs').map(({ mode }) => mode), ['mettreAJour', 'remplacer']);
|
||||
const charge = chargeDesObjectifs();
|
||||
for (const { mode, cle } of modesDImport('objectifs')) {
|
||||
assert.ok(t(cle).length > 0, cle);
|
||||
assert.equal(apercevoirObjectifs(lignes('table;objectif', '1;finance'), charge, { mode }).mode, mode);
|
||||
}
|
||||
assert.throws(() => modesDImport('tables'), RangeError);
|
||||
});
|
||||
|
||||
test('le mode proposé des objectifs : Mettre à jour, que la liste soit vide ou non ; jamais Remplacer', () => {
|
||||
assert.equal(modePropose([], 'objectifs'), 'mettreAJour');
|
||||
assert.equal(modePropose([{ id: 1, nom: 'Ombrelle' }], 'objectifs'), 'mettreAJour');
|
||||
assert.equal(modePropose([], 'participants'), 'ajouter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('modeleApercu des objectifs de table (§ 10.4)', () => {
|
||||
test('les colonnes, les quatre champs dans l’ordre de l’export, chacun sa clé ; les lignes : numéro, soirée ou tour, étiquettes une à une, règle nommée', () => {
|
||||
const charge = chargeDesObjectifs();
|
||||
const texte = lignes('Règle;Objectif;Tour;Table;Salle', ';finance|Montréal;;1;A', 'toutes;bénévole;2;2;B', 'ou;finance;1;4;C');
|
||||
const modele = modeleApercu({ nature: 'objectifs', encodage: 'utf-8', apercu: apercevoirObjectifs(texte, charge) });
|
||||
assert.equal(modele.global, null);
|
||||
assert.deepEqual(modele.colonnes.map(({ entete, cle }) => [entete, cle]), [
|
||||
['Règle', 'import.champ.regle'],
|
||||
['Objectif', 'import.champ.objectif'],
|
||||
['Tour', 'import.champ.tour'],
|
||||
['Table', 'import.champ.table'],
|
||||
['Salle', 'import.colonne.nonReconnue'],
|
||||
]);
|
||||
assert.equal(modele.champs, COLONNES_DES_OBJECTIFS);
|
||||
assert.deepEqual(COLONNES_DES_OBJECTIFS.map(({ champ }) => champ), [...CHAMPS_OBJECTIFS]);
|
||||
for (const { cle } of COLONNES_DES_OBJECTIFS) assert.ok(t(cle).length > 0, cle);
|
||||
assert.equal(modele.valides, 3);
|
||||
assert.deepEqual(modele.lignes, [
|
||||
{
|
||||
ligne: 2,
|
||||
cellules: [
|
||||
{ champ: 'table', texte: '1', cle: null, etiquettes: null },
|
||||
{ champ: 'tour', texte: null, cle: 'import.tour.soiree', etiquettes: null },
|
||||
{ champ: 'objectif', texte: null, cle: null, etiquettes: ['finance', 'Montréal'] },
|
||||
{ champ: 'regle', texte: null, cle: 'import.regle.defaut', etiquettes: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
ligne: 3,
|
||||
cellules: [
|
||||
{ champ: 'table', texte: '2', cle: null, etiquettes: null },
|
||||
{ champ: 'tour', texte: '2', cle: null, etiquettes: null },
|
||||
{ champ: 'objectif', texte: null, cle: null, etiquettes: ['bénévole'] },
|
||||
{ champ: 'regle', texte: null, cle: 'import.regle.et', etiquettes: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
ligne: 4,
|
||||
cellules: [
|
||||
{ champ: 'table', texte: '4', cle: null, etiquettes: null },
|
||||
{ champ: 'tour', texte: '1', cle: null, etiquettes: null },
|
||||
{ champ: 'objectif', texte: null, cle: null, etiquettes: ['finance'] },
|
||||
{ champ: 'regle', texte: null, cle: 'import.regle.ou', etiquettes: null },
|
||||
],
|
||||
},
|
||||
]);
|
||||
for (const cle of ['import.tour.soiree', 'import.regle.defaut', 'import.regle.ou', 'import.regle.et']) assert.ok(t(cle).length > 0, cle);
|
||||
for (const liste of ['fusions', 'doublons', 'homonymes']) assert.deepEqual(modele[liste], [], liste);
|
||||
assert.equal(modeleApercu({ encodage: 'utf-8', apercu: apercevoir(lignes('nom', 'Ombrelle')) }).champs, COLONNES_DES_LIGNES);
|
||||
});
|
||||
|
||||
test('un refus de chaque motif du § 10.4, dans l’ordre du texte, chacun un motif traduit qui nomme sa cause', () => {
|
||||
const modele = modeleApercu({ nature: 'objectifs', encodage: 'utf-8', apercu: apercevoirObjectifs(TEXTE_DES_MOTIFS, chargeDesObjectifs()) });
|
||||
assert.equal(modele.global, null);
|
||||
assert.deepEqual(modele.refusees.map(({ ligne, code }) => [ligne, code]), MOTIFS_ATTENDUS);
|
||||
assert.equal(new Set(MOTIFS_ATTENDUS.map(([, code]) => code)).size, 12);
|
||||
const motif = motifLigne(t);
|
||||
const textes = modele.refusees.map(({ code, details }) => motif(code, details));
|
||||
for (const [rang, texte] of textes.entries()) assert.ok(texte.length > 0, MOTIFS_ATTENDUS[rang][1]);
|
||||
assert.equal(new Set(textes).size, 12);
|
||||
const parCode = new Map(modele.refusees.map(({ code, details }) => [code, motif(code, details)]));
|
||||
assert.match(parCode.get('NUMERO_TABLE_INCONNU'), /9/);
|
||||
assert.match(parCode.get('TOUR_HORS_SOIREE'), /2 tours/);
|
||||
assert.match(parCode.get('REGLE_INCONNUE'), /peut-être/);
|
||||
assert.match(parCode.get('OBJECTIF_EN_DOUBLE'), /ligne 2/);
|
||||
assert.match(parCode.get('ETIQUETTES_TROP_NOMBREUSES'), /21/);
|
||||
});
|
||||
|
||||
test('les étiquettes fondues : un bloc à part, en information, jamais mêlé aux appartenances ; l’orthographe d’une personne de l’événement retenue', () => {
|
||||
const charge = chargeDesObjectifs({ participants: [porteuse(1, 'Ombrelle', ['Finance'])] });
|
||||
const modele = modeleApercu({ nature: 'objectifs', encodage: 'utf-8', apercu: apercevoirObjectifs(TEXTE_DES_MOTIFS, charge) });
|
||||
assert.deepEqual(modele.fusions, []);
|
||||
assert.deepEqual(modele.fusionsEtiquettes, [{ retenue: 'Finance', orthographes: ['FINANCE', 'Finance', 'finance'], lignes: 2 }]);
|
||||
assert.deepEqual(modele.lignes.find(({ ligne }) => ligne === 2).cellules[2].etiquettes, ['Finance']);
|
||||
});
|
||||
|
||||
test('participants : appartenances et étiquettes fondues, chacune dans son bloc', () => {
|
||||
const existants = [{ ...porteuse(4, 'Ombrelle', ['bénévole']), prenom: 'Iris', appartenance: 'Coopérative Bleue' }];
|
||||
const texte = lignes(
|
||||
'nom;prenom;appartenance;etiquettes',
|
||||
'Pervenche;Théo;cooperative bleue;Bénévole|finance',
|
||||
'Lacasse;Ondine;Grisaille;FINANCE',
|
||||
);
|
||||
const modele = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(texte, { participants: existants }) });
|
||||
assert.deepEqual(modele.fusions.map(({ affichee }) => affichee), ['Coopérative Bleue']);
|
||||
assert.deepEqual(modele.fusionsEtiquettes, [
|
||||
{ retenue: 'FINANCE', orthographes: ['FINANCE', 'finance'], lignes: 1 },
|
||||
{ retenue: 'bénévole', orthographes: ['Bénévole', 'bénévole'], lignes: 1 },
|
||||
]);
|
||||
for (const { affichee } of modele.fusions) assert.ok(!modele.fusionsEtiquettes.some(({ retenue }) => retenue === affichee));
|
||||
const sansFusion = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(lignes('nom;etiquettes', 'Ombrelle;finance')) });
|
||||
assert.deepEqual(sansFusion.fusionsEtiquettes, []);
|
||||
});
|
||||
|
||||
test('chaque refus global des objectifs : la clé de sa cause, celle de son remède ; un événement sans table mène à la section des tables', () => {
|
||||
const avecTables = chargeDesObjectifs();
|
||||
const sansTable = chargeDesObjectifs({ tables: [] });
|
||||
const cas = [
|
||||
['CARACTERE_NUL', apercevoirObjectifs('table;objectif\n1;finance\u{0}\n', avecTables), 'import.remede.caractereNulCollage'],
|
||||
['AUCUN_ENTETE', apercevoirObjectifs(lignes('a;b', '1;finance'), avecTables), 'import.remede.aucunEnteteObjectifs'],
|
||||
['TABLE_NON_ASSOCIEE', apercevoirObjectifs(lignes('tour;objectif', '1;finance'), avecTables), 'import.remede.tableNonAssociee'],
|
||||
['OBJECTIF_NON_ASSOCIE', apercevoirObjectifs(lignes('table;tour', '1;1'), avecTables), 'import.remede.objectifNonAssocie'],
|
||||
['AUCUNE_LIGNE_VALIDE', apercevoirObjectifs(lignes('table;objectif', '9;finance'), avecTables), 'import.remede.aucuneLigneValide'],
|
||||
['GUILLEMET_OUVERT', apercevoirObjectifs(lignes('table;objectif', '"1;finance'), avecTables), 'import.remede.guillemetOuvert'],
|
||||
[
|
||||
'SEPARATEUR_INTROUVABLE',
|
||||
apercevoirObjectifs(lignes('table;objectif', '1;finance;x', '2'), avecTables),
|
||||
'import.remede.separateurIntrouvable',
|
||||
],
|
||||
];
|
||||
for (const [code, apercu, remede] of cas) {
|
||||
assert.equal(apercu.refusGlobal, code, code);
|
||||
const { global } = modeleApercu({ nature: 'objectifs', encodage: 'utf-8', apercu });
|
||||
assert.equal(global.cle, code, code);
|
||||
assert.deepEqual(global.remede, { cle: remede, details: global.details, dansLaCause: false, geste: null }, code);
|
||||
assert.ok(t(global.remede.cle, global.remede.details).length > 0, code);
|
||||
assert.ok(t(global.cle, global.details).length > 0, code);
|
||||
}
|
||||
const apercu = apercevoirObjectifs(lignes('table;objectif', '1;finance'), sansTable);
|
||||
assert.equal(apercu.refusGlobal, 'OBJECTIFS_SANS_TABLE');
|
||||
const { global } = modeleApercu({ nature: 'objectifs', encodage: 'utf-8', apercu });
|
||||
assert.deepEqual(global, {
|
||||
cle: 'OBJECTIFS_SANS_TABLE',
|
||||
details: {},
|
||||
remede: { cle: null, details: {}, dansLaCause: true, geste: { geste: 'ouvrirSection', section: 'tables' } },
|
||||
});
|
||||
assert.ok(t(global.cle).length > 0);
|
||||
});
|
||||
|
||||
test('un collage sous Objectifs : la lecture dans l’ordre de la grille ne s’offre pas, et le remède ne la nomme pas', () => {
|
||||
const charge = chargeDesObjectifs();
|
||||
const texte = lignes('1;finance', '2;bénévole');
|
||||
const apercu = apercevoirObjectifs(texte, charge);
|
||||
assert.equal(apercu.refusGlobal, 'AUCUN_ENTETE');
|
||||
assert.equal(avecEntetesDeLaGrille(texte, apercu, 'objectifs'), null);
|
||||
assert.equal(modeleApercu({ nature: 'objectifs', apercu }).global.remede.cle, 'import.remede.aucunEnteteObjectifs');
|
||||
const participants = apercevoir(texte);
|
||||
assert.notEqual(avecEntetesDeLaGrille(texte, participants), null);
|
||||
});
|
||||
|
||||
test('une nature inconnue lève, jamais un modèle deviné', () => {
|
||||
assert.throws(() => modeleApercu({ nature: 'tables', apercu: apercevoir(lignes('nom', 'Ombrelle')) }), RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('le rapport et la fenêtre de « Remplacer » selon la nature', () => {
|
||||
test('les comptes du rapport : ceux du résumé de chaque import, chacun une clé qui s’accorde au nombre', () => {
|
||||
assert.deepEqual(comptesDuRapport('participants').map(({ nom }) => nom), ['ajoutes', 'misAJour', 'inchanges', 'retires', 'reservationsRetirees']);
|
||||
assert.deepEqual(comptesDuRapport('objectifs').map(({ nom }) => nom), ['ajoutes', 'remplaces', 'inchanges', 'retires', 'tables']);
|
||||
for (const nature of ['participants', 'objectifs']) {
|
||||
for (const { nom, cle } of comptesDuRapport(nature)) {
|
||||
assert.notEqual(t(cle, { n: 1 }), t(cle, { n: 2 }), `${nature} ${nom}`);
|
||||
}
|
||||
}
|
||||
assert.match(t('import.rapport.objectifsAjoutes', { n: 2 }), /objectifs ajoutés/);
|
||||
assert.match(t('import.rapport.tablesTouchees', { n: 1 }), /table/);
|
||||
assert.throws(() => comptesDuRapport('tables'), RangeError);
|
||||
});
|
||||
|
||||
test('remplacer des objectifs annonce les deux comptes de bilanRemplacementObjectifs, accordés', () => {
|
||||
assert.equal(t('import.remplacer.objectifs', { n: 3 }), '3 objectifs de table seront supprimés');
|
||||
assert.equal(t('import.remplacer.objectifs', { n: 1 }), '1 objectif de table sera supprimé');
|
||||
assert.equal(t('import.remplacer.tables', { n: 2 }), '2 tables perdront leurs objectifs');
|
||||
assert.equal(t('import.remplacer.tables', { n: 1 }), '1 table perdra ses objectifs');
|
||||
assert.ok(t('import.remplacer.titreObjectifs').length > 0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -79,6 +79,27 @@ export const TABLE = new Map([
|
|||
placesManquantes === 1 ? '1 place manque à chaque tour' : `${placesManquantes} places manquent à chaque tour`,
|
||||
],
|
||||
|
||||
// --- Les objectifs hors d'atteinte (§ 5.6, A10) -----------------------------
|
||||
// Un plancher par valeur que le mode des objectifs compare : le texte dit
|
||||
// ce qu'aucun placement n'évite.
|
||||
[
|
||||
'diagnostic.objectifs.toursHorsObjectif',
|
||||
({ plancher }) =>
|
||||
`au moins ${compte(plancher, 'tour hors objectif', 'tours hors objectif')} : ` +
|
||||
"les tables qui portent un objectif n'ont pas assez de places libres pour chaque tour possible",
|
||||
],
|
||||
[
|
||||
'diagnostic.objectifs.jamaisServies',
|
||||
({ plancher }) =>
|
||||
`au moins ${compte(plancher, 'personne jamais servie', 'personnes jamais servies')} par une table à objectif, ` +
|
||||
'quel que soit le placement',
|
||||
],
|
||||
[
|
||||
'diagnostic.objectifs.horsObjectifMax',
|
||||
({ plancher }) =>
|
||||
`au moins une personne passe au moins ${compte(plancher, 'tour', 'tours')} hors objectif, quel que soit le placement`,
|
||||
],
|
||||
|
||||
// --- Le plafond a priori et les imposés (§ 5.4, § 5.5) ---------------------
|
||||
[
|
||||
'diagnostic.plafond',
|
||||
|
|
@ -126,5 +147,9 @@ export const TABLE = new Map([
|
|||
],
|
||||
['diagnostic.remede.places', "ajouter des tables ou des places, ou exclure des personnes : d'ici là, la génération est refusée"],
|
||||
['diagnostic.remede.plafond', 'seules les capacités et les réservations le changent : aucune relance ne le dépasse'],
|
||||
[
|
||||
'diagnostic.remede.objectifs',
|
||||
"ajouter des places aux tables qui portent un objectif, ou donner un objectif à d'autres tables, abaisse ce plancher",
|
||||
],
|
||||
['diagnostic.remede.imposes', 'les réservations les imposent : ils ne comptent pas parmi les retours que choisit le moteur'],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -2,15 +2,21 @@
|
|||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les libellés de la génération : meilleure proposition du classement,
|
||||
// réglages, cœurs de calcul, durée observée, question d'une génération
|
||||
// longue, avancement et issue (§ 5.4, § 5.7, § 5.10, § 14.6). Chaque
|
||||
// réglages, mode des objectifs, ordre des priorités, cœurs de calcul, durée
|
||||
// observée, question d'une génération longue, avancement et issue (§ 5.3,
|
||||
// § 5.4, § 5.7, § 5.10, § 14.6). Chaque
|
||||
// clé commence par « generation. ». Le libellé d'un réglage porte son unité,
|
||||
// entre parenthèses : l'étiquette du champ la dit, et le nom accessible du
|
||||
// champ avec elle. Un nombre et une durée se mettent en forme par
|
||||
// creerFormat, sur la composition que la traduction passe à l'entrée ; les
|
||||
// détails reçus sont des nombres.
|
||||
// détails reçus sont des nombres, ou le nom d'un critère du registre, que le
|
||||
// texte écrit sous son nom complet, mobilier.critere.<nom>.
|
||||
import { creerFormat } from '../../application/format.js';
|
||||
import { compte } from '../../application/libelles/aides.js';
|
||||
import { cite, compte, enumeration } from '../../application/libelles/aides.js';
|
||||
|
||||
// Le nom complet d'un critère, cité (§ 5.7) : celui que l'application donne
|
||||
// à mobilier.critere.<nom>, le même que le journal écrit.
|
||||
const nomCritere = (critere, texte) => cite(texte(`mobilier.critere.${critere}`));
|
||||
|
||||
// Les cœurs d'un calcul, dits quand il en emploie plus d'un : « , sur
|
||||
// 4 cœurs » ; rien pour un seul, ou quand le nombre n'est pas donné.
|
||||
|
|
@ -53,8 +59,84 @@ export const TABLE = new Map([
|
|||
['generation.contrainte.nouveauxVoisins', 'Nouveaux voisins à chaque tour'],
|
||||
['generation.contrainte.nouvelleTable', 'Nouvelle table à chaque tour'],
|
||||
['generation.contrainte.varierAppartenances', 'Varier les appartenances rencontrées'],
|
||||
['generation.contrainte.respecterObjectifs', 'Respecter les objectifs des tables'],
|
||||
['generation.contrainte.rencontrerAnimateurs', "Rencontrer le plus d'animateurs différents"],
|
||||
['generation.contrainte.attribuerSieges', 'Attribuer les sièges'],
|
||||
|
||||
// --- Le mode des objectifs, sous sa case (§ 5.3, A8, D20, D22) -----------------
|
||||
// Le troisième mode garde le libellé de l'utilisateur : sa clé est le seul
|
||||
// emploi admis de son mot (D22) ; son explication dit ce qu'il compare.
|
||||
['generation.reglage.modeObjectifs', "Objectifs des tables : chercher d'abord"],
|
||||
['generation.reglage.modeObjectifs.places', 'le plus de places servies'],
|
||||
['generation.reglage.modeObjectifs.chacun', 'chacun au moins une fois'],
|
||||
['generation.reglage.modeObjectifs.minimum', "l'équité d'abord"],
|
||||
[
|
||||
'generation.reglage.modeObjectifs.places.explication',
|
||||
'Le plus grand total de tours servis : le critère compare les tours hors objectif.',
|
||||
],
|
||||
[
|
||||
'generation.reglage.modeObjectifs.chacun.explication',
|
||||
"D'abord le plus de personnes servies à un tour au moins, puis le plus grand total : le critère compare " +
|
||||
'les personnes jamais servies, puis les tours hors objectif.',
|
||||
],
|
||||
[
|
||||
'generation.reglage.modeObjectifs.minimum.explication',
|
||||
"Le minimum d'abord, puis le total : d'abord la personne la plus mal servie, mesurée contre ses propres " +
|
||||
'tours possibles, puis le plus grand total. Le critère compare le plus de tours hors objectif ' +
|
||||
"d'une personne, puis les tours hors objectif.",
|
||||
],
|
||||
// L'état du critère des tours hors objectif, lu dans la table d'évaluation.
|
||||
[
|
||||
'generation.reglage.modeObjectifs.etat.decoche',
|
||||
"Tours hors objectif : critère décoché. Le mode ne change rien tant que la case n'est pas cochée.",
|
||||
],
|
||||
[
|
||||
'generation.reglage.modeObjectifs.etat.sansObjet',
|
||||
"Tours hors objectif : critère sans objet. Aucune table à objectif ne peut servir personne : le mode ne change rien.",
|
||||
],
|
||||
|
||||
// --- L'ordre des priorités (§ 5.7, A11, A12) -------------------------------------
|
||||
['generation.reglage.priorites', 'Ordre des priorités'],
|
||||
[
|
||||
'generation.priorites.note',
|
||||
'Deux propositions se comparent palier par palier : un palier ne départage que des propositions égales sur ' +
|
||||
'ceux qui le précèdent. Dans un palier de plusieurs critères, chacun compte selon son poids.',
|
||||
],
|
||||
['generation.priorites.parDefaut', 'Ordre par défaut : chaque critère seul à son palier.'],
|
||||
['generation.priorites.regle', "Ordre réglé pour l'événement."],
|
||||
// palier, compté à partir de 1 ; paliers, leur nombre.
|
||||
[
|
||||
'generation.priorites.palier',
|
||||
({ palier, paliers }, texte) => {
|
||||
const { nombre } = creerFormat(texte);
|
||||
return `Palier ${nombre(palier)} sur ${nombre(paliers)}`;
|
||||
},
|
||||
],
|
||||
['generation.priorites.etat.decoche', 'décoché'],
|
||||
['generation.priorites.etat.sansObjet', 'sans objet'],
|
||||
['generation.priorites.poids', ({ critere }, texte) => `Poids de ${nomCritere(critere, texte)} (nombre entier)`],
|
||||
// Les gestes d'un critère, chacun nommant le critère qu'il déplace :
|
||||
// chaque bouton de l'éditeur porte ainsi un nom à lui (§ 14.5).
|
||||
['generation.priorites.monter', ({ critere }, texte) => `Monter ${nomCritere(critere, texte)} d'un palier`],
|
||||
['generation.priorites.monterPalier', ({ critere }, texte) => `Monter le palier de ${nomCritere(critere, texte)}`],
|
||||
['generation.priorites.descendre', ({ critere }, texte) => `Descendre ${nomCritere(critere, texte)} d'un palier`],
|
||||
['generation.priorites.descendrePalier', ({ critere }, texte) => `Descendre le palier de ${nomCritere(critere, texte)}`],
|
||||
['generation.priorites.joindre', ({ critere }, texte) => `Joindre ${nomCritere(critere, texte)} au palier du dessus`],
|
||||
['generation.priorites.separer', ({ critere }, texte) => `Séparer ${nomCritere(critere, texte)} en palier propre`],
|
||||
['generation.priorites.retablir', "Rétablir l'ordre par défaut"],
|
||||
// L'annonce d'un geste abouti : critere, le critère déplacé ; palier,
|
||||
// compté à partir de 1 ; paliers ; avec, ses compagnons de palier.
|
||||
[
|
||||
'generation.priorites.annonce',
|
||||
({ critere, palier, paliers, avec }, texte) => {
|
||||
const { nombre } = creerFormat(texte);
|
||||
const compagnons =
|
||||
avec.length === 0 ? ', seul' : `, avec ${enumeration(avec.map((autre) => nomCritere(autre, texte)))}`;
|
||||
return `${nomCritere(critere, texte)} est au palier ${nombre(palier)} sur ${nombre(paliers)}${compagnons}.`;
|
||||
},
|
||||
],
|
||||
['generation.priorites.annonceRetablie', "L'ordre par défaut est rétabli."],
|
||||
|
||||
// --- Les cœurs de calcul, un réglage du poste pour la séance (§ 5.10) --------
|
||||
// disponibles : les processeurs logiques du poste, hyperthreads compris.
|
||||
['generation.coeurs.titre', 'Cœurs de calcul'],
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les libellés de l'import d'une liste : aperçu et rapport (§ 14.6). Chaque
|
||||
// Les libellés de l'import d'une liste de participants ou d'objectifs de
|
||||
// table : nature du fichier, aperçu et rapport (§ 10.4, § 14.6). Chaque
|
||||
// clé commence par « import. ». Un nombre, un nom ou un rang s'affiche dans
|
||||
// son propre nœud de texte : les libellés qui le suivent ne reçoivent que ce
|
||||
// qui accorde le nom au compte.
|
||||
|
|
@ -29,6 +30,11 @@ export const TABLE = new Map([
|
|||
['import.sorte.avis', 'Avis'],
|
||||
['import.sorte.information', 'Information'],
|
||||
|
||||
// --- La nature du fichier, choisie avant l'aperçu (§ 10.4) --------------------
|
||||
['import.nature.titre', 'Contenu du fichier'],
|
||||
['import.nature.participants', 'Participants'],
|
||||
['import.nature.objectifs', 'Objectifs de table'],
|
||||
|
||||
// --- Le mode de l'import (§ 10.1) -------------------------------------------
|
||||
['import.mode.titre', "Mode de l'import"],
|
||||
['import.mode.ajouter', 'Ajouter'],
|
||||
|
|
@ -63,6 +69,14 @@ export const TABLE = new Map([
|
|||
['import.champ.exclu', 'exclu'],
|
||||
['import.champ.notes', 'notes'],
|
||||
['import.champ.etiquettes', 'étiquettes'],
|
||||
['import.champ.table', 'table'],
|
||||
['import.champ.tour', 'tour'],
|
||||
['import.champ.objectif', 'objectif'],
|
||||
['import.champ.regle', 'règle'],
|
||||
['import.tour.soiree', 'soirée'],
|
||||
['import.regle.ou', 'au moins une'],
|
||||
['import.regle.et', 'toutes'],
|
||||
['import.regle.defaut', 'règle par défaut'],
|
||||
['import.exclu.oui', 'oui'],
|
||||
['import.exclu.non', 'non'],
|
||||
['import.colonne.nonReconnue', 'non reconnue : ignorée'],
|
||||
|
|
@ -78,6 +92,10 @@ export const TABLE = new Map([
|
|||
['import.apercu.fusions', 'Orthographes fondues en une appartenance'],
|
||||
['import.apercu.orthographeLignes', ({ n }) => accorde(n, 'ligne', 'lignes')],
|
||||
['import.apercu.affichee', 'affichée :'],
|
||||
['import.apercu.fusionsEtiquettes', 'Orthographes fondues en une étiquette'],
|
||||
['import.apercu.retenue', 'retenue :'],
|
||||
['import.apercu.orthographes', 'orthographes :'],
|
||||
['import.apercu.lignesReecrites', ({ n }) => accorde(n, 'ligne réécrite', 'lignes réécrites')],
|
||||
['import.apercu.doublons', 'Doublons signalés, jamais fusionnés'],
|
||||
['import.apercu.doublonLignes', 'lignes'],
|
||||
['import.apercu.doublonExistants', 'déjà dans la liste :'],
|
||||
|
|
@ -92,6 +110,9 @@ export const TABLE = new Map([
|
|||
"Ajoutez une ligne d'en-tête qui nomme les colonnes, ou lisez les colonnes dans l'ordre de la grille.",
|
||||
],
|
||||
['import.remede.nomNonAssocie', 'Nommez « nom » la colonne des noms.'],
|
||||
['import.remede.aucunEnteteObjectifs', "Ajoutez une ligne d'en-tête qui nomme les colonnes, « table » et « objectif » au moins."],
|
||||
['import.remede.tableNonAssociee', 'Nommez « table » la colonne des numéros de table.'],
|
||||
['import.remede.objectifNonAssocie', 'Nommez « objectif » la colonne des étiquettes visées.'],
|
||||
['import.remede.aucuneLigneValide', 'Corrigez les lignes refusées, listées avec leur motif.'],
|
||||
[
|
||||
'import.remede.guillemetOuvert',
|
||||
|
|
@ -128,6 +149,14 @@ export const TABLE = new Map([
|
|||
({ n }) => `${n} ${accorde(n, 'proposition deviendra périmée', 'propositions deviendront périmées')}`,
|
||||
],
|
||||
|
||||
// --- La fenêtre de « Remplacer » des objectifs (§ 10.4) ----------------------
|
||||
['import.remplacer.titreObjectifs', 'Remplacer les objectifs des tables'],
|
||||
[
|
||||
'import.remplacer.objectifs',
|
||||
({ n }) => `${n} ${accorde(n, 'objectif de table sera supprimé', 'objectifs de table seront supprimés')}`,
|
||||
],
|
||||
['import.remplacer.tables', ({ n }) => `${n} ${accorde(n, 'table perdra ses objectifs', 'tables perdront leurs objectifs')}`],
|
||||
|
||||
// --- Le rapport (§ 10.1) ---------------------------------------------------
|
||||
['import.rapport.titre', "Rapport d'import"],
|
||||
['import.rapport.ajoutes', ({ n }) => accorde(n, 'personne ajoutée', 'personnes ajoutées')],
|
||||
|
|
@ -136,5 +165,14 @@ export const TABLE = new Map([
|
|||
['import.rapport.retires', ({ n }) => accorde(n, 'personne retirée', 'personnes retirées')],
|
||||
['import.rapport.reservationsRetirees', ({ n }) => accorde(n, 'réservation retirée', 'réservations retirées')],
|
||||
['import.rapport.rienEcrit', "Rien n'a changé dans la liste : seules des lignes refusées restent à lire."],
|
||||
['import.rapport.objectifsAjoutes', ({ n }) => accorde(n, 'objectif ajouté', 'objectifs ajoutés')],
|
||||
['import.rapport.objectifsRemplaces', ({ n }) => accorde(n, 'objectif remplacé', 'objectifs remplacés')],
|
||||
['import.rapport.objectifsInchanges', ({ n }) => accorde(n, 'objectif inchangé', 'objectifs inchangés')],
|
||||
['import.rapport.objectifsRetires', ({ n }) => accorde(n, 'objectif retiré', 'objectifs retirés')],
|
||||
['import.rapport.tablesTouchees', ({ n }) => accorde(n, 'table dont les objectifs changent', 'tables dont les objectifs changent')],
|
||||
[
|
||||
'import.rapport.rienEcritObjectifs',
|
||||
"Aucun objectif de table n'a changé : seules des lignes refusées restent à lire.",
|
||||
],
|
||||
['import.rapport.designees', 'désignées :'],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@
|
|||
// participants.entete.comptes compose la ligne de comptes de la carte
|
||||
// (comptesEntete, entete.js), ses parties séparées d'un point médian ;
|
||||
// participants.legende.* nomme la légende des appartenances et chacune de
|
||||
// ses pastilles, le nom puis le compte.
|
||||
// ses pastilles, le nom puis le compte ; participants.legendeEtiquettes.titre
|
||||
// nomme la légende des étiquettes, dont chaque pastille se compose comme
|
||||
// celle d'une appartenance. participants.etiquettes.* porte la saisie des
|
||||
// étiquettes sur la fiche : son champ, son ajout, et le retrait nommé par
|
||||
// l'étiquette qu'il ôte.
|
||||
//
|
||||
// participants.colonne.* nomme chaque colonne de COLONNES (grille.js), dans
|
||||
// l'en-tête de la grille, l'étiquette d'un champ du formulaire et celle
|
||||
|
|
@ -69,6 +73,7 @@ export const TABLE = new Map([
|
|||
],
|
||||
['participants.legende.titre', 'Appartenances'],
|
||||
['participants.legende.element', ({ nom, compte: n }) => `${nom} \u{00B7} ${n}`],
|
||||
['participants.legendeEtiquettes.titre', 'Étiquettes'],
|
||||
|
||||
// --- Le formulaire d'ajout (§ 10.2) ---------------------------------------
|
||||
['participants.formulaire.titre', 'Ajouter une personne'],
|
||||
|
|
@ -158,6 +163,13 @@ export const TABLE = new Map([
|
|||
['participants.fiche.horsRetenu', 'hors du placement retenu'],
|
||||
['participants.fiche.siegeNonAttribue', 'non attribué'],
|
||||
['participants.fiche.aucunRetenu', 'Aucun placement retenu ne la nomme.'],
|
||||
['participants.fiche.etiquettes', 'Étiquettes'],
|
||||
['participants.fiche.aucuneEtiquette', 'Aucune étiquette.'],
|
||||
|
||||
// --- Les étiquettes de la fiche (§ 4.5, § 10.2) ---------------------------
|
||||
['participants.etiquettes.champ', 'Ajouter des étiquettes, séparées par une virgule'],
|
||||
['participants.etiquettes.ajouter', 'Ajouter'],
|
||||
['participants.etiquettes.retirer', ({ motCle }) => `Retirer « ${motCle} »`],
|
||||
|
||||
// --- Les statuts calculés (§ 4.2) -----------------------------------------
|
||||
['participants.statut.ancre', 'ancré'],
|
||||
|
|
|
|||
|
|
@ -7,32 +7,49 @@
|
|||
// Chaque en-tête de colonne nomme son unité (§ 5.4) : collisions cumulées,
|
||||
// paires distinctes et excédent ne se confondent jamais. Des rencontres, le
|
||||
// tableau ne porte que le minimum, sur les trois populations du § 5.4 ; la
|
||||
// moyenne n'y paraît pas. La phrase de l'ordre compose les noms des critères
|
||||
// par leurs clés moteur.critere.<nom>, dans l'ordre reçu, puis l'identifiant
|
||||
// croissant, qui départage deux propositions égales sur tous (§ 5.7).
|
||||
// moyenne n'y paraît pas. La phrase de l'ordre nomme les paliers appliqués,
|
||||
// chaque valeur qu'un palier applique par sa clé moteur.valeur.<valeur>,
|
||||
// puis l'identifiant croissant, qui départage deux propositions égales sur
|
||||
// tous (§ 5.7, A11).
|
||||
import { compte } from '../../application/libelles/aides.js';
|
||||
|
||||
// Le nom d'un critère du classement, celui de la table du moteur.
|
||||
const critere = (nom, texte) => texte(`moteur.critere.${nom}`);
|
||||
|
||||
// Un palier, ses critères {nom, poids} : le nom du critère seul ; pour un
|
||||
// palier partagé, « ensemble, » puis chaque critère suivi de son poids.
|
||||
// Les noms d'un critère appliqué, {valeurs} : chaque valeur qu'il classe,
|
||||
// séparées par « puis ».
|
||||
const valeursDe = ({ valeurs }, texte) => valeurs.map((valeur) => texte(`moteur.valeur.${valeur}`)).join(texte('propositions.ordre.separateur'));
|
||||
|
||||
// Un palier, ses critères {nom, poids, valeurs} : les noms du critère seul ;
|
||||
// pour un palier partagé, « ensemble, » puis chaque critère suivi de son
|
||||
// poids. Les mêmes morceaux que la phrase de l'ordre (palierEnMorceaux,
|
||||
// propositions/modele.js).
|
||||
const palier = (criteres, texte) =>
|
||||
criteres.length === 1
|
||||
? critere(criteres[0].nom, texte)
|
||||
: `${texte('moteur.palier.ensemble')}, ` +
|
||||
criteres.map(({ nom, poids }) => `${critere(nom, texte)} (${texte('moteur.palier.poids', { poids })})`).join(', ');
|
||||
? valeursDe(criteres[0], texte)
|
||||
: texte('propositions.ordre.ensemble') +
|
||||
criteres
|
||||
.map((applique) => `${valeursDe(applique, texte)}${texte('propositions.ordre.poids', { poids: applique.poids })}`)
|
||||
.join(texte('propositions.ordre.virgule'));
|
||||
|
||||
export const TABLE = new Map([
|
||||
// --- La section (§ 5.7) ------------------------------------------------------
|
||||
['propositions.tableau.titre', 'Propositions classées'],
|
||||
['propositions.aucune', "Aucune proposition : la génération en produit, et chaque génération s'ajoute aux précédentes."],
|
||||
// La phrase des critères se compose de morceaux, chacun sa clé : le début,
|
||||
// les noms, leur séparateur, l'identifiant croissant et la fin. L'affichage
|
||||
// les met bout à bout, chaque nom dans son propre élément ;
|
||||
// propositions.ordre.criteres les assemble en une seule chaîne, la même.
|
||||
// les noms des valeurs appliquées, le séparateur des paliers, les mots d'un
|
||||
// palier partagé — « ensemble, », le poids qui suit chaque critère, la
|
||||
// virgule entre deux critères —, l'identifiant croissant et la fin.
|
||||
// L'affichage les met bout à bout, chaque nom dans son propre élément.
|
||||
// propositions.ordre.criteres assemble en une seule chaîne celle de l'ordre
|
||||
// fixe de référence, sur la liste de ses critères : sous l'ordre par
|
||||
// défaut, les quatre cases cochées, sans objectif ni case des animateurs,
|
||||
// la phrase affichée l'égale mot pour mot.
|
||||
['propositions.ordre.debut', "Ordre du classement, chaque critère au plus faible d'abord : "],
|
||||
['propositions.ordre.separateur', ', puis '],
|
||||
['propositions.ordre.ensemble', (_, texte) => `${texte('moteur.palier.ensemble')}, `],
|
||||
['propositions.ordre.poids', ({ poids }, texte) => ` (${texte('moteur.palier.poids', { poids })})`],
|
||||
['propositions.ordre.virgule', ', '],
|
||||
['propositions.ordre.identifiant', "l'identifiant croissant"],
|
||||
['propositions.ordre.fin', '.'],
|
||||
[
|
||||
|
|
@ -43,9 +60,9 @@ export const TABLE = new Map([
|
|||
texte('propositions.ordre.fin'),
|
||||
],
|
||||
// Le critère sauté, puis le palier appliqué à sa place : un critère seul
|
||||
// s'y nomme ; un palier partagé se lit « ensemble, » suivi de chaque
|
||||
// critère et de son poids. applique null, aucun palier ne prend sa place,
|
||||
// et la seconde phrase se tait.
|
||||
// s'y nomme par les valeurs qu'il applique ; un palier partagé se lit
|
||||
// « ensemble, » suivi de chaque critère et de son poids. applique null,
|
||||
// aucun palier ne prend sa place, et la seconde phrase se tait.
|
||||
[
|
||||
'propositions.ordre.saute',
|
||||
({ saute, applique }, texte) =>
|
||||
|
|
@ -88,25 +105,36 @@ export const TABLE = new Map([
|
|||
['propositions.colonne.effectifManque', 'Personnes à qui il en manque'],
|
||||
['propositions.colonne.ecartItineraireMax', "Écart d'itinéraire maximal (personnes)"],
|
||||
['propositions.colonne.ecartAuPlafondAPriori', 'Écart au plafond a priori maximal (personnes)'],
|
||||
['propositions.colonne.jamaisServies', 'Personnes jamais servies par un objectif'],
|
||||
['propositions.colonne.horsObjectifMax', "Tours hors objectif d'une personne, au plus"],
|
||||
['propositions.colonne.toursHorsObjectif', 'Tours hors objectif (personne et tour)'],
|
||||
['propositions.colonne.toursSelonObjectif', "Tours selon l'objectif s(p), minimum"],
|
||||
['propositions.colonne.animateursRencontres', 'Animateurs rencontrés m(p), minimum'],
|
||||
['propositions.colonne.animateursNonRencontres', 'Animateurs non rencontrés (personne et animateur)'],
|
||||
['propositions.colonne.certificat', 'Certificat'],
|
||||
['propositions.colonne.imposes', 'Imposé par les réservations'],
|
||||
['propositions.colonne.commandes', 'Commandes'],
|
||||
|
||||
// --- Les groupes d'en-têtes, au-dessus des colonnes -------------------------
|
||||
// Chaque groupe réunit des colonnes voisines ; les en-têtes de colonne
|
||||
// gardent chacun son unité.
|
||||
// gardent chacun son unité. Le sixième ne paraît que quand le critère des
|
||||
// objectifs ou celui des animateurs compte (A11).
|
||||
['propositions.groupe.rencontres', 'Personnes rencontrées'],
|
||||
['propositions.groupe.collisions', "Collisions d'appartenance"],
|
||||
['propositions.groupe.repetitions', 'Rencontres répétées et retours'],
|
||||
['propositions.groupe.diversite', "Diversité d'appartenance"],
|
||||
['propositions.groupe.manque', 'Manque et plafond a priori'],
|
||||
['propositions.groupe.objectifs', 'Objectifs des tables et animateurs'],
|
||||
|
||||
// --- Les explications, une par colonne (§ 5.4, § 5.5, § 5.7, § 12.10.4) --------
|
||||
// Le texte de l'infobulle d'un en-tête, et celui du nom de critère qui
|
||||
// désigne la colonne dans la phrase de l'ordre : un critère et sa colonne
|
||||
// partagent la clé. Chacune dit ce que la colonne compte, sur quelle
|
||||
// population, et dans quel sens elle sert le mieux quand ce sens existe ;
|
||||
// les termes sont ceux du glossaire du § 5.5.
|
||||
// Le texte de l'infobulle d'un en-tête, et celui du nom de la valeur
|
||||
// appliquée qui désigne la colonne dans la phrase de l'ordre : une valeur
|
||||
// appliquée et sa colonne partagent la clé. Chacune dit ce que la colonne
|
||||
// compte, sur quelle population, et dans quel sens elle sert le mieux quand
|
||||
// ce sens existe ; les termes sont ceux du glossaire du § 5.5. Celle d'une
|
||||
// valeur qui classe ne fixe aucun rang : l'ordre se règle, et sa place se
|
||||
// lit dans la phrase de l'ordre au-dessus du tableau ; un rang par défaut
|
||||
// s'y dit comme tel.
|
||||
[
|
||||
'propositions.explication.rang',
|
||||
"La place de la proposition dans le classement, 1 pour la première, selon l'ordre écrit au-dessus du tableau. " +
|
||||
|
|
@ -137,7 +165,8 @@ export const TABLE = new Map([
|
|||
[
|
||||
'propositions.explication.collisionsCumulees',
|
||||
'Chaque fois que deux personnes de la même appartenance partagent une table, compté une fois par paire et par tour : ' +
|
||||
"deux collègues ensemble aux quatre tours font 4. Critère du classement après l'excédent, il départage deux " +
|
||||
"deux collègues ensemble aux quatre tours font 4. Critère du classement à la place que lui donne " +
|
||||
"l'ordre écrit au-dessus du tableau ; l'ordre par défaut le lit après l'excédent, pour départager deux " +
|
||||
"propositions de même excédent. Plus il est bas, mieux c'est.",
|
||||
],
|
||||
[
|
||||
|
|
@ -148,15 +177,16 @@ export const TABLE = new Map([
|
|||
],
|
||||
[
|
||||
'propositions.explication.excedentCollisions',
|
||||
'Les retrouvailles entre personnes de la même appartenance au-delà de leur première rencontre, soit les collisions ' +
|
||||
'cumulées moins les paires distinctes : deux collègues ensemble aux quatre tours font 3, six collègues croisés une ' +
|
||||
'fois chacun font 0. Critère du classement avant les collisions cumulées, car passer la soirée avec le même ' +
|
||||
"collègue est pire que croiser six collègues une fois. Plus il est bas, mieux c'est.",
|
||||
'Les retrouvailles entre personnes de même appartenance après leur première rencontre, soit les collisions ' +
|
||||
'cumulées moins les paires distinctes : deux collègues ensemble aux quatre tours font 3. Critère du classement ' +
|
||||
"placé par l'ordre écrit au-dessus du tableau, par défaut avant les collisions cumulées : passer la soirée avec " +
|
||||
"le même collègue est pire que croiser six collègues une fois. Plus il est bas, mieux c'est.",
|
||||
],
|
||||
[
|
||||
'propositions.explication.rencontresRepetees',
|
||||
"Le nombre de paires de personnes réunies à plus d'un tour que le placement a choisies ; celles qu'imposent les " +
|
||||
'réservations sont dans la colonne des imposés. Critère du classement après les collisions cumulées. ' +
|
||||
"réservations sont dans la colonne des imposés. Critère du classement à la place que lui donne l'ordre écrit " +
|
||||
'au-dessus du tableau. ' +
|
||||
"Plus il est bas, mieux c'est.",
|
||||
],
|
||||
[
|
||||
|
|
@ -166,9 +196,11 @@ export const TABLE = new Map([
|
|||
],
|
||||
[
|
||||
'propositions.explication.retoursChoisis',
|
||||
"Le nombre de fois où une personne revient à une table où elle s'est déjà assise, additionné sur toutes les personnes. " +
|
||||
"Seuls comptent les retours que le placement a choisis ; ceux qu'imposent les réservations sont dans la colonne " +
|
||||
"des imposés. Plus il est bas, mieux c'est.",
|
||||
"Le nombre de fois où une personne revient à une table où elle s'est déjà assise, additionné sur toutes les personnes ; " +
|
||||
"seuls comptent les retours que le placement a choisis, ceux qu'imposent les réservations étant dans la colonne " +
|
||||
"des imposés. Critère du classement quand seule « nouvelle table » est cochée, là où l'ordre écrit au-dessus du " +
|
||||
"tableau place les rencontres répétées. " +
|
||||
"Plus il est bas, mieux c'est.",
|
||||
],
|
||||
[
|
||||
'propositions.explication.appartenancesVues',
|
||||
|
|
@ -180,7 +212,8 @@ export const TABLE = new Map([
|
|||
'propositions.explication.totalRedondance',
|
||||
"r(p) : le nombre de personnes que p rencontre et qui ont une appartenance, moins A(p), le nombre d'appartenances " +
|
||||
"différentes qu'elles portent ; à 0, aucune appartenance n'est recroisée. La colonne donne la somme sur toutes les " +
|
||||
"personnes, Σ r(p), dernier critère du classement avant le numéro. Plus elle est basse, mieux c'est.",
|
||||
"personnes, Σ r(p), critère du classement à la place que lui donne l'ordre écrit au-dessus du tableau. " +
|
||||
"Plus elle est basse, mieux c'est.",
|
||||
],
|
||||
[
|
||||
'propositions.explication.diversite',
|
||||
|
|
@ -209,10 +242,56 @@ export const TABLE = new Map([
|
|||
],
|
||||
[
|
||||
'propositions.explication.ecartAuPlafondAPriori',
|
||||
"L'écart au plafond a priori d'une personne : son plafond a priori, le plus de rencontres que permettent les " +
|
||||
"Pour chaque personne, son plafond a priori, le plus de rencontres que permettent les " +
|
||||
"capacités des tables sur le meilleur itinéraire, moins ses rencontres, soit son manque plus son écart d'itinéraire. " +
|
||||
"La colonne donne le plus grand, premier critère du classement, sauté quand le plafond a priori n'a pas pu être " +
|
||||
"calculé, la cellule écrivant alors « inconnu ». Plus il est bas, mieux c'est.",
|
||||
"La colonne donne le plus grand, critère du classement à sa place dans l'ordre écrit au-dessus du tableau ; " +
|
||||
"sans plafond a priori calculé, il est sauté et la cellule écrit « inconnu ». " +
|
||||
"Plus il est bas, mieux c'est.",
|
||||
],
|
||||
// Les colonnes des objectifs des tables et des animateurs (A9) : une
|
||||
// personne est concernée par les objectifs quand une table qui lui
|
||||
// correspond peut l'accueillir à un tour au moins, par les animateurs
|
||||
// quand elle peut en rencontrer un là où il anime.
|
||||
[
|
||||
'propositions.explication.jamaisServies',
|
||||
"Les personnes concernées par un objectif de table, celles qui ont au moins un tour possible, que le placement " +
|
||||
"n'assied jamais à une table dont l'objectif leur correspond. Critère du classement sous le mode « chacun au moins " +
|
||||
"une fois », lu avant les tours hors objectif, au palier que l'ordre écrit au-dessus du tableau " +
|
||||
"donne aux objectifs. " +
|
||||
"Plus il est bas, mieux c'est.",
|
||||
],
|
||||
[
|
||||
'propositions.explication.horsObjectifMax',
|
||||
"Pour chaque personne concernée par un objectif, ses tours possibles moins ses tours selon l'objectif ; la colonne " +
|
||||
"donne le plus grand, celui de la personne la moins servie. Critère du classement sous le mode qui sert d'abord " +
|
||||
"la personne la moins servie, lu avant les tours hors objectif, au palier que l'ordre écrit au-dessus du " +
|
||||
"tableau donne aux objectifs. Plus il est bas, mieux c'est.",
|
||||
],
|
||||
[
|
||||
'propositions.explication.toursHorsObjectif',
|
||||
"Les couples d'une personne et d'un tour où une table dont l'objectif lui correspond pouvait l'accueillir, mais où " +
|
||||
"le placement l'assied ailleurs, additionnés sur toutes les personnes. Critère du classement quand des objectifs " +
|
||||
"de table peuvent servir quelqu'un, à la place que lui donne l'ordre écrit au-dessus du tableau. " +
|
||||
"Plus il est bas, mieux c'est.",
|
||||
],
|
||||
[
|
||||
'propositions.explication.toursSelonObjectif',
|
||||
"s(p) : le nombre de tours où p est assis à une table dont l'objectif lui correspond. La colonne donne le plus " +
|
||||
"petit s(p) parmi les personnes concernées, celles qu'une table à objectif peut accueillir à un tour au moins. " +
|
||||
"Plus il est grand, mieux c'est.",
|
||||
],
|
||||
[
|
||||
'propositions.explication.animateursRencontres',
|
||||
"m(p) : le nombre d'animateurs différents que p rencontre à un tour où ils animent, un animateur revu comptant " +
|
||||
"une fois. La colonne donne le plus petit m(p) parmi les personnes qui peuvent en rencontrer au moins un. " +
|
||||
"Plus il est grand, mieux c'est.",
|
||||
],
|
||||
[
|
||||
'propositions.explication.animateursNonRencontres',
|
||||
"Pour chaque personne, le nombre d'animateurs qu'elle pouvait rencontrer là où ils animent, selon les tables et " +
|
||||
"les réservations, moins ceux qu'elle rencontre, additionné sur toutes les personnes. Critère du classement quand " +
|
||||
"la case des animateurs est cochée, à la place que lui donne l'ordre écrit au-dessus du tableau. " +
|
||||
"Plus il est bas, mieux c'est.",
|
||||
],
|
||||
[
|
||||
'propositions.explication.certificat',
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
// composé par texte depuis son code. Les noms de personnes et les numéros de
|
||||
// table viennent des détails : une fenêtre à confirmer nomme chaque élément
|
||||
// détruit dans son propre libellé.
|
||||
import { JOINTURE_ETIQUETTES } from '../../application/csv.js';
|
||||
import { cite, compte } from '../../application/libelles/aides.js';
|
||||
|
||||
// Un accord au nombre : le singulier jusqu'à un, comme compte.
|
||||
|
|
@ -19,6 +20,14 @@ const accord = (n, singulier, pluriel) => (n <= 1 ? singulier : pluriel);
|
|||
// La portée d'une réservation en mots : tous les tours, ou le tour désigné.
|
||||
const portee = ({ portee: genre, tour }) => (genre === 'tous' ? 'pour tous les tours' : `au tour ${tour}`);
|
||||
|
||||
// Les étiquettes d'un objectif, citées et jointes comme l'export les
|
||||
// joint : aucun « et » ni « ou » entre elles, qui dirait une règle que
|
||||
// l'objectif ne porte pas forcément.
|
||||
const etiquettesCitees = (etiquettes) => etiquettes.map(cite).join(JOINTURE_ETIQUETTES);
|
||||
|
||||
// Ce qu'une règle exige des étiquettes, en mots (§ 4.5).
|
||||
const exigence = (regle) => (regle === 'et' ? 'toutes' : 'au moins une');
|
||||
|
||||
export const TABLE = new Map([
|
||||
// --- Le défaut (§ 6.1) ------------------------------------------------------
|
||||
['tables.defaut.etiquette', 'Places par défaut'],
|
||||
|
|
@ -118,6 +127,10 @@ export const TABLE = new Map([
|
|||
`${reservation.siege === null ? '' : `, siège ${reservation.siege}`}`,
|
||||
],
|
||||
['tables.suppression.titre', ({ siege, libelle }) => `Titre ${cite(libelle)}, siège ${siege}`],
|
||||
[
|
||||
'tables.suppression.objectif',
|
||||
({ tour, etiquettes }) => `Objectif ${tour === null ? 'de la soirée' : `du tour ${tour}`} : ${etiquettesCitees(etiquettes)}`,
|
||||
],
|
||||
|
||||
// --- Les tours (§ 2.1 étape 5, § 4.3) ----------------------------------------
|
||||
['tables.tours.etiquette', 'Nombre de tours'],
|
||||
|
|
@ -129,6 +142,30 @@ export const TABLE = new Map([
|
|||
'tables.tours.reservation',
|
||||
({ nom, numero, tour }) => `${nom} : réservation à la table ${numero} au tour ${tour}, détruite`,
|
||||
],
|
||||
[
|
||||
'tables.tours.objectif',
|
||||
({ numero, tour, etiquettes }) => `Table ${numero} : objectif du tour ${tour} (${etiquettesCitees(etiquettes)}), détruit`,
|
||||
],
|
||||
|
||||
// --- Les objectifs des tables (§ 4.5, § 10.4, D3, D19) ------------------------
|
||||
// La règle par défaut de l'événement, réglée avec les places et les tours.
|
||||
['tables.regle.etiquette', 'Règle par défaut des objectifs'],
|
||||
['tables.regle.ou', 'au moins une (OU)'],
|
||||
['tables.regle.et', 'toutes (ET)'],
|
||||
// L'objectif d'une table : son champ d'étiquettes, le choix de sa règle —
|
||||
// le réglage général, nommé par ce qu'il exige, ou une règle propre —, et
|
||||
// son état en mots.
|
||||
['tables.objectif.soiree', 'Objectif de la soirée'],
|
||||
['tables.objectif.tour', ({ tour }) => `Objectif du tour ${tour}`],
|
||||
['tables.objectif.regle', "Règle de l'objectif"],
|
||||
['tables.objectif.regleDefaut', ({ regle }) => `selon le réglage général (${exigence(regle)})`],
|
||||
['tables.objectif.suitDefaut', 'Suit la règle par défaut'],
|
||||
['tables.objectif.reglePropre', 'Règle propre'],
|
||||
['tables.objectif.retirer', "Retirer l'objectif"],
|
||||
// Le bouton qui déplie les objectifs par tour, et combien de tours en
|
||||
// portent un.
|
||||
['tables.objectifs.parTour', ({ nombre }) => (nombre === 0 ? 'Objectifs par tour' : `Objectifs par tour (${nombre})`)],
|
||||
['tables.objectifs.exporter', 'Exporter les objectifs'],
|
||||
|
||||
// Un placement qu'un changement laisse en dérive (§ 9) : il reste, et ne
|
||||
// correspond plus à la configuration.
|
||||
|
|
|
|||
|
|
@ -2,18 +2,43 @@
|
|||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La fiche d'une personne à l'écran (§ 4, § 4.2, § 7.4) : nom, prénom,
|
||||
// appartenance, courriel, titre pressenti, notes, statut calculé, ses
|
||||
// réservations, et sa table et son siège à chaque tour du retenu ; un
|
||||
// tour que le retenu ne porte pas se dit hors du retenu. La fiche
|
||||
// replie, ne tronque jamais : un nom de deux cents caractères s'y lit
|
||||
// entier, sur autant de lignes qu'il en faut. Chaque donnée occupe son
|
||||
// propre nœud de texte, et rien n'y est interprété comme du balisage.
|
||||
// La fiche d'une personne à l'écran (§ 4, § 4.2, § 4.5, § 7.4, § 10.2) :
|
||||
// nom, prénom, appartenance, courriel, titre pressenti, notes, statut
|
||||
// calculé, ses étiquettes, ses réservations, et sa table et son siège à
|
||||
// chaque tour du retenu ; un tour que le retenu ne porte pas se dit hors
|
||||
// du retenu. La fiche replie, ne tronque jamais : un nom de deux cents
|
||||
// caractères s'y lit entier, sur autant de lignes qu'il en faut. Chaque
|
||||
// donnée occupe son propre nœud de texte, et rien n'y est interprété
|
||||
// comme du balisage.
|
||||
//
|
||||
// Le statut est celui de statuts (src/application/places.js), lu dans
|
||||
// l'instance du moteur ; l'itinéraire, celui d'itineraire (grille.js), lu
|
||||
// tour par tour dans etatPlaces : la fiche ne recalcule ni l'un ni
|
||||
// l'autre, et ne porte aucun contrôle de saisie.
|
||||
// l'autre. Son seul contrôle de saisie est le champ des étiquettes.
|
||||
//
|
||||
// Les étiquettes (etiquettes.js) : une pastille neutre à icône par
|
||||
// étiquette, dans l'ordre de la charge — la teinte d'appartenance ne sert
|
||||
// qu'à l'appartenance (§ 12.7) —, chacune suivie de sa commande
|
||||
// « Retirer », nommée par elle ; puis un formulaire d'ajout, dont le
|
||||
// champ reçoit une ou plusieurs étiquettes séparées comme dans une
|
||||
// cellule CSV. Entrée, ou « Ajouter », envoie la saisie entière à
|
||||
// changerEtiquettes, qui la découpe, la met en forme et la réconcilie : la
|
||||
// fiche ne lit rien elle-même. Un refus de l'ajout — étiquette mal formée,
|
||||
// trop d'étiquettes, séance en lecture, plan bloqué — se pose contre le
|
||||
// champ, avec son remède, la saisie gardée ; une frappe le retire. Un
|
||||
// ajout abouti vide le champ, sauf ce qui a été tapé pendant l'écriture,
|
||||
// et pose contre lui le signal de chaque orthographe retenue
|
||||
// (ORTHOGRAPHE_RETENUE), tant que la révision qui l'a produit est la
|
||||
// courante et la fiche celle de la personne. Le refus d'un retrait se
|
||||
// pose à côté de sa commande (Commande). En lecture comme sur un plan
|
||||
// bloqué, pastilles et commandes restent : la séance refuse le geste, et
|
||||
// le refus porte son remède. Une saisie non envoyée est un geste ouvert
|
||||
// (geste.js), qui suspend le compte d'inactivité ; l'envoi n'est pas
|
||||
// ré-entrant. Passer à une autre personne vide le champ et son refus ; le
|
||||
// refus d'un envoi qui revient après ce passage n'est posé nulle part.
|
||||
// Un retrait abouti rend le focus au « Retirer » qui prend la place du
|
||||
// sien — le suivant, sinon le précédent (ligneVoisine) —, ou au champ
|
||||
// quand il n'en reste aucun.
|
||||
//
|
||||
// La fiche est une carte : fond et filet de la carte, rayon de 12 px,
|
||||
// ombre légère ; ses étiquettes et légendes de tableau en petites
|
||||
|
|
@ -22,15 +47,24 @@
|
|||
// Ouverte, ou passée à une autre personne, la fiche se fait défiler
|
||||
// jusque dans la fenêtre : dans une fenêtre étroite, elle se pose sous la
|
||||
// grille.
|
||||
import { getContext } from 'svelte';
|
||||
import { getContext, onDestroy, tick, untrack } from 'svelte';
|
||||
import { nomAffiche } from '../../application/personnes.js';
|
||||
import { etatPlaces, statuts } from '../../application/places.js';
|
||||
import { cleDeColonne, cleDeStatut, itineraire, reservationsDe } from './grille.js';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Icone from '../primitives/Icone.svelte';
|
||||
import Pastille from '../primitives/Pastille.svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import { creerGeste } from '../primitives/geste.js';
|
||||
import { ajoutEtiquettes, etiquettesDeLaFiche, retraitEtiquette, saisieApresAjout, signauxOrthographe } from './etiquettes.js';
|
||||
import { cleDeColonne, cleDeStatut, itineraire, ligneVoisine, reservationsDe } from './grille.js';
|
||||
|
||||
let { participant, charge } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const ui = getContext('ui');
|
||||
const seance = getContext('seance');
|
||||
const uid = $props.id();
|
||||
const geste = creerGeste(seance);
|
||||
|
||||
// Les valeurs de la fiche, sous l'étiquette de leur colonne.
|
||||
const CHAMPS = ['nom', 'prenom', 'appartenance', 'courriel', 'titrePressenti', 'notes'];
|
||||
|
|
@ -44,10 +78,92 @@
|
|||
const reservations = $derived(reservationsDe(charge, participant.id));
|
||||
const etapes = $derived(itineraire(participant.id, charge.evenement.tours, (tour) => etatPlaces(charge, { tour })));
|
||||
|
||||
// La fiche et son champ d'ajout, montés : le défilement vise la fiche,
|
||||
// le focus d'après un retrait ses « Retirer » ou le champ.
|
||||
let element;
|
||||
let champ;
|
||||
$effect(() => {
|
||||
if (participant.id !== null) element.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
|
||||
// La saisie des étiquettes, le refus posé contre son champ, le rendu du
|
||||
// dernier ajout abouti, et l'envoi en cours.
|
||||
let texte = $state('');
|
||||
let refusChamp = $state.raw(null);
|
||||
let dernier = $state.raw(null);
|
||||
let envoiEnCours = $state(false);
|
||||
const idChamp = `${uid}-etiquettes`;
|
||||
const idRefus = `${uid}-refus-etiquettes`;
|
||||
const idSignal = `${uid}-orthographe`;
|
||||
|
||||
const pastilles = $derived(etiquettesDeLaFiche(participant));
|
||||
const signaux = $derived(signauxOrthographe(dernier, { revision: $seance.evenement?.revision ?? null, participant: participant.id }));
|
||||
const decrit = $derived(refusChamp !== null ? idRefus : signaux.length > 0 ? idSignal : undefined);
|
||||
|
||||
// Une autre personne : le champ repart vide, sans refus.
|
||||
let personneSaisie = untrack(() => participant.id);
|
||||
$effect.pre(() => {
|
||||
if (participant.id === personneSaisie) return;
|
||||
personneSaisie = participant.id;
|
||||
untrack(() => {
|
||||
texte = '';
|
||||
refusChamp = null;
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
geste.tenir(texte !== '');
|
||||
});
|
||||
|
||||
// Le refus d'un geste, ou une erreur sans code, qui remonte.
|
||||
const refusDe = (erreur) => {
|
||||
if (typeof erreur?.code !== 'string') throw erreur;
|
||||
return { code: erreur.code, details: erreur.details ?? {} };
|
||||
};
|
||||
|
||||
async function ajouter(evenement) {
|
||||
evenement.preventDefault();
|
||||
if (envoiEnCours) return;
|
||||
refusChamp = null;
|
||||
const envoyee = texte;
|
||||
const id = participant.id;
|
||||
const { nom, args } = ajoutEtiquettes(id, envoyee);
|
||||
envoiEnCours = true;
|
||||
try {
|
||||
const rendu = await seance.executer(nom, args);
|
||||
dernier = { revision: seance.etat().evenement.revision, participant: id, avertissements: rendu.avertissements };
|
||||
texte = saisieApresAjout(envoyee, texte);
|
||||
} catch (erreur) {
|
||||
const refus = refusDe(erreur);
|
||||
if (participant.id === id) refusChamp = refus;
|
||||
} finally {
|
||||
envoiEnCours = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Retire motCle ; aboutie, quand le bouton détruit a laissé le focus au
|
||||
// document, le porte au « Retirer » voisin ou au champ. Un refus remonte
|
||||
// à Commande, qui le pose à côté du bouton resté en place.
|
||||
async function retirer(motCle) {
|
||||
const id = participant.id;
|
||||
const rang = pastilles.indexOf(motCle);
|
||||
const { nom, args } = retraitEtiquette(id, motCle);
|
||||
await seance.executer(nom, args);
|
||||
await tick();
|
||||
if (participant.id !== id) return;
|
||||
const actuel = document.activeElement;
|
||||
if (actuel !== null && actuel !== document.body) return;
|
||||
const voisin = ligneVoisine(rang, pastilles.length);
|
||||
const boutons = element.querySelectorAll('[data-etiquettes] [data-commande="retirerEtiquette"]');
|
||||
(voisin === null ? champ : (boutons[voisin] ?? champ))?.focus();
|
||||
}
|
||||
|
||||
// Fait défiler l'élément monté jusque dans la fenêtre, au plus court.
|
||||
function montrer(cible) {
|
||||
cible.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
onDestroy(() => geste.liberer());
|
||||
</script>
|
||||
|
||||
<aside bind:this={element} class="fiche" data-fiche={participant.id} aria-label={t('participants.fiche.titre')}>
|
||||
|
|
@ -67,6 +183,63 @@
|
|||
<dd>{statut}</dd>
|
||||
</dl>
|
||||
|
||||
<section class="etiquettes" data-etiquettes aria-labelledby={`${uid}-titre-etiquettes`}>
|
||||
<h4 id={`${uid}-titre-etiquettes`}>{t('participants.fiche.etiquettes')}</h4>
|
||||
{#if pastilles.length > 0}
|
||||
<ul>
|
||||
{#each pastilles as motCle (motCle)}
|
||||
<li>
|
||||
<Pastille sorte="statut" valeur="neutre" icone="etiquettes" libelle={motCle} />
|
||||
<Commande
|
||||
cle="participants.etiquettes.retirer"
|
||||
details={{ motCle }}
|
||||
nom="retirerEtiquette"
|
||||
variante="discrete"
|
||||
executer={() => retirer(motCle)}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p>{t('participants.fiche.aucuneEtiquette')}</p>
|
||||
{/if}
|
||||
<form novalidate onsubmit={ajouter}>
|
||||
<label for={idChamp}>{t('participants.etiquettes.champ')}</label>
|
||||
<div class="saisie">
|
||||
<input
|
||||
bind:this={champ}
|
||||
id={idChamp}
|
||||
type="text"
|
||||
data-champ="etiquettes"
|
||||
bind:value={texte}
|
||||
aria-invalid={refusChamp === null ? undefined : 'true'}
|
||||
aria-describedby={decrit}
|
||||
oninput={() => (refusChamp = null)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
data-commande="ajouterEtiquettes"
|
||||
aria-busy={envoiEnCours ? 'true' : undefined}
|
||||
aria-disabled={envoiEnCours ? 'true' : undefined}>{t('participants.etiquettes.ajouter')}</button
|
||||
>
|
||||
</div>
|
||||
{#if refusChamp !== null}
|
||||
<div class="refus" {@attach montrer}>
|
||||
<Refus id={idRefus} refus={refusChamp} lever={() => (refusChamp = null)} />
|
||||
</div>
|
||||
{:else if signaux.length > 0}
|
||||
<div class="signal" id={idSignal} role="status" data-avertissement="ORTHOGRAPHE_RETENUE" {@attach montrer}>
|
||||
<span class="signe"><Icone nom="triangle" /></span>
|
||||
<div>
|
||||
{#each signaux as signal, rang (rang)}
|
||||
<p>{t(signal.code, signal.details)}</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<table>
|
||||
<caption>{t('participants.fiche.reservations')}</caption>
|
||||
{#if reservations.length > 0}
|
||||
|
|
@ -181,7 +354,8 @@
|
|||
}
|
||||
|
||||
dt,
|
||||
caption {
|
||||
caption,
|
||||
h4 {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font-size: var(--taille-intertitre);
|
||||
font-weight: bold;
|
||||
|
|
@ -224,6 +398,114 @@
|
|||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
|
||||
/* Les étiquettes : l'intitulé en petites capitales, les pastilles en
|
||||
rangée qui se replie, chacune suivie de son « Retirer » ; puis le champ
|
||||
d'ajout et son bouton sur une ligne, le refus ou le signal dessous. */
|
||||
.etiquettes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
margin: var(--espace-3) 0;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-1) var(--espace-3);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--espace-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.etiquettes p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: var(--taille-texte-petit);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.saisie {
|
||||
display: flex;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
input {
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 2.5rem;
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-carte-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input[aria-invalid='true'] {
|
||||
border-color: var(--couleur-refus);
|
||||
}
|
||||
|
||||
input:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
button[aria-disabled='true'] {
|
||||
border-style: dashed;
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.refus {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* L'orthographe retenue : un avis, son signe, un texte de l'encre de la
|
||||
page. */
|
||||
.signal {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: var(--espace-2);
|
||||
padding: var(--espace-2);
|
||||
border: 1px solid var(--couleur-avis-filet);
|
||||
border-left: 3px solid var(--couleur-avis-marque);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-avis-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font-size: var(--taille-texte-petit);
|
||||
}
|
||||
|
||||
.signal .signe {
|
||||
display: inline-flex;
|
||||
color: var(--couleur-avis-marque);
|
||||
}
|
||||
|
||||
.signal p {
|
||||
color: var(--couleur-page-texte);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: var(--espace-1) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@
|
|||
// § 5.9, § 7.4, § 10.2) : la saisie par le seul nom, l'appartenance
|
||||
// collante, la grille au clavier, le tri, le filtre et le compte,
|
||||
// l'exclusion, le manque de places à l'ajout et à la réintégration, la fiche
|
||||
// et l'itinéraire, l'export, le collage, et la liste groupée par une
|
||||
// colonne ou plusieurs, ses groupes imbriqués, repliés et dépliés, et les
|
||||
// conflits du placement montré sous ses groupes. Chaque épreuve monte App par
|
||||
// et l'itinéraire, les étiquettes de la fiche et leur légende, l'export, le
|
||||
// collage, et la liste groupée par une colonne ou plusieurs, ses groupes
|
||||
// imbriqués, repliés et dépliés, et les conflits du placement montré sous
|
||||
// ses groupes. Chaque épreuve monte App par
|
||||
// monterApp, sur le système de fichiers simulé et l'horloge pilotée ; chaque
|
||||
// écriture se relève (trace) et chaque entrée se compte dans le journal. Les
|
||||
// gestes passent par des entrées que Chromium traite comme réelles
|
||||
|
|
@ -836,7 +837,8 @@ describe('la fiche (§ 4.2, § 7.4)', () => {
|
|||
await rendu();
|
||||
expect(fiche(m).dataset.fiche).toBe(String(id));
|
||||
expect(valeurDeFiche(m, 'participants.fiche.statut').textContent).toBe(t(cleDeStatut(statut)));
|
||||
expect(fiche(m).querySelectorAll('input, select, textarea')).toHaveLength(0);
|
||||
// Le seul contrôle de saisie de la fiche est le champ des étiquettes.
|
||||
expect([...fiche(m).querySelectorAll('input, select, textarea')].map((champ) => champ.dataset.champ)).toEqual(['etiquettes']);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -927,6 +929,317 @@ describe('la fiche (§ 4.2, § 7.4)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('les étiquettes : la fiche et la légende (§ 4.5, § 10.2, § 5.9)', () => {
|
||||
// La rubrique des étiquettes de la fiche, son champ d'ajout, sa commande
|
||||
// d'ajout, et la légende des étiquettes de la section.
|
||||
const rubrique = (m) => fiche(m).querySelector('[data-etiquettes]');
|
||||
const champEtiquettes = (m) => fiche(m).querySelector('[data-champ="etiquettes"]');
|
||||
const ajoutEtiquettes = (m) => fiche(m).querySelector('[data-commande="ajouterEtiquettes"]');
|
||||
const retrait = (m, motCle) =>
|
||||
[...fiche(m).querySelectorAll('[data-commande="retirerEtiquette"]')].find(
|
||||
(bouton) => bouton.textContent.trim() === t('participants.etiquettes.retirer', { motCle }),
|
||||
) ?? null;
|
||||
const legendeEtiquettes = (m) => section(m).querySelector('[data-legende-etiquettes]');
|
||||
const etiquettesDe = (m, nom) => participants(m).find((p) => p.nom === nom).etiquettes;
|
||||
|
||||
// Trois personnes de deux appartenances : les teintes d'appartenance
|
||||
// existent, et aucune ne doit peindre une étiquette.
|
||||
const TROIS_ETIQUETEES = [
|
||||
{ nom: 'Ombrelle', prenom: 'Iris', appartenance: CLUB },
|
||||
{ nom: 'Pervenche', prenom: 'Théo', appartenance: 'Cercle des Pinsons' },
|
||||
{ nom: 'Lacasse', prenom: 'Ondine', appartenance: CLUB, exclu: true },
|
||||
];
|
||||
|
||||
// La fiche de la personne nommée, ouverte.
|
||||
async function ouvrirFiche(m, nom) {
|
||||
m.ui.ouvrirFiche(idDe(m, nom));
|
||||
await rendu();
|
||||
}
|
||||
|
||||
// Une saisie dans le champ des étiquettes, envoyée par Entrée.
|
||||
async function ajouterParClavier(m, texte) {
|
||||
await saisir(champEtiquettes(m), texte);
|
||||
champEtiquettes(m).focus();
|
||||
await touche('Enter');
|
||||
await attendreSeance(m);
|
||||
}
|
||||
|
||||
test('la fiche montre chaque étiquette en pastille neutre à icône, sa commande « Retirer » nommée par elle ; sans étiquette, elle le dit', async () => {
|
||||
const m = await evenementOuvert({ personnes: TROIS_ETIQUETEES });
|
||||
await m.seance.executer('changerEtiquettes', { id: idDe(m, 'Ombrelle'), ajouter: 'finance, Montréal' });
|
||||
await ouvrirFiche(m, 'Ombrelle');
|
||||
const elements = [...rubrique(m).querySelectorAll('li')];
|
||||
expect(elements.map((li) => texteDe(pastilleDans(li)))).toEqual(['Montréal', 'finance']);
|
||||
const encre = jeton('--couleur-page-texte');
|
||||
for (const li of elements) {
|
||||
const pastille = pastilleDans(li);
|
||||
expect(fondDe(pastille)).toEqual(jeton('--couleur-neutre-fond'));
|
||||
expect(pastille.querySelector('svg')).not.toBeNull();
|
||||
expect(composantes(getComputedStyle(pastille).color)).toEqual(encre);
|
||||
}
|
||||
// La teinte d'appartenance ne sert qu'à l'appartenance (§ 12.7).
|
||||
const teintes = new Set([1, 2, 3, 4, 5, 6].map((rang) => JSON.stringify(jeton(`--couleur-appartenance-${rang}-fond`))));
|
||||
for (const li of elements) expect(teintes.has(JSON.stringify(fondDe(pastilleDans(li))))).toBe(false);
|
||||
for (const motCle of ['Montréal', 'finance']) expect(retrait(m, motCle), motCle).not.toBeNull();
|
||||
// En sombre, la pastille prend le neutre sombre.
|
||||
await sousTheme('sombre', async () => {
|
||||
for (const li of elements) expect(fondDe(pastilleDans(li))).toEqual(jeton('--couleur-neutre-fond'));
|
||||
});
|
||||
|
||||
await ouvrirFiche(m, 'Pervenche');
|
||||
expect(rubrique(m).querySelectorAll('li')).toHaveLength(0);
|
||||
expect(rubrique(m).textContent).toContain(t('participants.fiche.aucuneEtiquette'));
|
||||
expect(fiche(m).querySelectorAll('[data-commande="retirerEtiquette"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('au clavier : une saisie de trois étiquettes, Entrée, une entrée au journal ; le champ se vide et garde le focus ; « Retirer » ôte la sienne et rend le focus à sa voisine', async () => {
|
||||
const m = await evenementOuvert({ personnes: TROIS_ETIQUETEES });
|
||||
await ouvrirFiche(m, 'Pervenche');
|
||||
const champ = champEtiquettes(m);
|
||||
expect(document.querySelector(`label[for="${champ.id}"]`).textContent.trim()).toBe(t('participants.etiquettes.champ'));
|
||||
expect(ajoutEtiquettes(m).textContent.trim()).toBe(t('participants.etiquettes.ajouter'));
|
||||
const avant = entrees(m).length;
|
||||
const gestes = vi.spyOn(m.seance, 'debuterGeste');
|
||||
const finis = vi.spyOn(m.seance, 'finirGeste');
|
||||
await saisir(champ, 'ressources humaines ; bénévole ; voile');
|
||||
// Une saisie non envoyée est un geste ouvert.
|
||||
expect(gestes).toHaveBeenCalled();
|
||||
expect(m.seance.gesteEnCours).toBe(true);
|
||||
champ.focus();
|
||||
await touche('Enter');
|
||||
await attendreSeance(m);
|
||||
expect(etiquettesDe(m, 'Pervenche')).toEqual(['bénévole', 'ressources humaines', 'voile']);
|
||||
expect(entrees(m)).toHaveLength(avant + 1);
|
||||
expect(champEtiquettes(m).value).toBe('');
|
||||
// Le champ vidé par l'envoi rend le geste : le compte d'inactivité reprend.
|
||||
expect(finis).toHaveBeenCalledTimes(1);
|
||||
expect(m.seance.gesteEnCours).toBe(false);
|
||||
expect(document.activeElement).toBe(champEtiquettes(m));
|
||||
expect([...rubrique(m).querySelectorAll('li')].map((li) => texteDe(pastilleDans(li)))).toEqual(['bénévole', 'ressources humaines', 'voile']);
|
||||
|
||||
// « Retirer » activé par Entrée : le focus passe au « Retirer » suivant,
|
||||
// sinon au précédent, sinon au champ ; jamais au document.
|
||||
const retirerAuClavier = async (motCle) => {
|
||||
retrait(m, motCle).focus();
|
||||
await touche('Enter');
|
||||
await attendreSeance(m);
|
||||
};
|
||||
await retirerAuClavier('ressources humaines');
|
||||
expect(etiquettesDe(m, 'Pervenche')).toEqual(['bénévole', 'voile']);
|
||||
expect(entrees(m)).toHaveLength(avant + 2);
|
||||
expect(retrait(m, 'ressources humaines')).toBeNull();
|
||||
expect(document.activeElement).toBe(retrait(m, 'voile'));
|
||||
|
||||
await retirerAuClavier('voile');
|
||||
expect(etiquettesDe(m, 'Pervenche')).toEqual(['bénévole']);
|
||||
expect(document.activeElement).toBe(retrait(m, 'bénévole'));
|
||||
|
||||
// Le dernier retrait retire le champ de la personne.
|
||||
await retirerAuClavier('bénévole');
|
||||
expect(etiquettesDe(m, 'Pervenche')).toBeUndefined();
|
||||
expect(rubrique(m).textContent).toContain(t('participants.fiche.aucuneEtiquette'));
|
||||
expect(document.activeElement).toBe(champEtiquettes(m));
|
||||
|
||||
// Une saisie non envoyée : fermer la fiche rend le geste qu'elle tient.
|
||||
await saisir(champEtiquettes(m), 'voile');
|
||||
expect(m.seance.gesteEnCours).toBe(true);
|
||||
finis.mockClear();
|
||||
await cliquer(fiche(m).querySelector('[data-commande="fermerFiche"]'));
|
||||
expect(fiche(m)).toBeNull();
|
||||
expect(finis).toHaveBeenCalledTimes(1);
|
||||
expect(m.seance.gesteEnCours).toBe(false);
|
||||
});
|
||||
|
||||
test('« FINANCE » saisi quand « finance » existe s’enregistre sous « finance », et le signal d’orthographe se pose contre le champ', async () => {
|
||||
const m = await evenementOuvert({ personnes: TROIS_ETIQUETEES });
|
||||
await m.seance.executer('changerEtiquettes', { id: idDe(m, 'Ombrelle'), ajouter: 'finance' });
|
||||
await ouvrirFiche(m, 'Pervenche');
|
||||
await ajouterParClavier(m, 'FINANCE');
|
||||
expect(etiquettesDe(m, 'Pervenche')).toEqual(['finance']);
|
||||
const signal = fiche(m).querySelector('[data-avertissement="ORTHOGRAPHE_RETENUE"]');
|
||||
expect(signal).not.toBeNull();
|
||||
expect(visible(signal)).toBe(true);
|
||||
expect(texteDe(signal)).toBe(t('ORTHOGRAPHE_RETENUE', { saisie: 'FINANCE', retenue: 'finance' }));
|
||||
expect(champEtiquettes(m).getAttribute('aria-describedby')).toBe(signal.id);
|
||||
// Contre le champ : le signal suit le champ dans la fiche, sans rien entre eux.
|
||||
expect(ecart(signal, champEtiquettes(m))).toBeLessThan(48);
|
||||
|
||||
// Une autre fiche ne le montre pas ; revenue à la personne, sous la même
|
||||
// révision, la fiche le montre de nouveau.
|
||||
await ouvrirFiche(m, 'Ombrelle');
|
||||
expect(fiche(m).querySelector('[data-avertissement]')).toBeNull();
|
||||
expect(champEtiquettes(m).hasAttribute('aria-describedby')).toBe(false);
|
||||
await ouvrirFiche(m, 'Pervenche');
|
||||
expect(fiche(m).querySelector('[data-avertissement="ORTHOGRAPHE_RETENUE"]')).not.toBeNull();
|
||||
|
||||
// Un geste suivant le retire.
|
||||
await ajouterParClavier(m, 'bénévole');
|
||||
expect(fiche(m).querySelector('[data-avertissement="ORTHOGRAPHE_RETENUE"]')).toBeNull();
|
||||
expect(champEtiquettes(m).hasAttribute('aria-describedby')).toBe(false);
|
||||
});
|
||||
|
||||
test('refusé contre le champ : une tabulation, 41 caractères, des séparateurs seuls, une vingt et unième ; la saisie reste, rien ne s’écrit', async () => {
|
||||
const m = await evenementOuvert({ personnes: TROIS_ETIQUETEES });
|
||||
const vingt = Array.from({ length: 20 }, (_, i) => `mot${String(i).padStart(2, '0')}`).join(', ');
|
||||
await m.seance.executer('changerEtiquettes', { id: idDe(m, 'Ombrelle'), ajouter: vingt });
|
||||
await ouvrirFiche(m, 'Pervenche');
|
||||
const avant = texteJournal(m);
|
||||
const cas = [
|
||||
['voile, a\u{0009}b', 'ETIQUETTE_INVALIDE'],
|
||||
['x'.repeat(41), 'ETIQUETTE_INVALIDE'],
|
||||
[' , ; | ', 'ETIQUETTE_INVALIDE'],
|
||||
];
|
||||
for (const [saisie, code] of cas) {
|
||||
await ajouterParClavier(m, saisie);
|
||||
const champ = champEtiquettes(m);
|
||||
expect(champ.getAttribute('aria-invalid'), saisie).toBe('true');
|
||||
const message = document.getElementById(champ.getAttribute('aria-describedby'));
|
||||
expect(message.dataset.refus, saisie).toBe(code);
|
||||
expect(visible(message)).toBe(true);
|
||||
expect(champ.value, saisie).toBe(saisie);
|
||||
expect(fiche(m).contains(message)).toBe(true);
|
||||
}
|
||||
expect(texteJournal(m)).toBe(avant);
|
||||
// Une frappe retire le refus.
|
||||
await saisir(champEtiquettes(m), 'voile');
|
||||
expect(champEtiquettes(m).hasAttribute('aria-invalid')).toBe(false);
|
||||
|
||||
await ouvrirFiche(m, 'Ombrelle');
|
||||
await ajouterParClavier(m, 'voile');
|
||||
const champ = champEtiquettes(m);
|
||||
const message = document.getElementById(champ.getAttribute('aria-describedby'));
|
||||
expect(message.dataset.refus).toBe('ETIQUETTES_EN_TROP');
|
||||
expect(texteDe(message)).toContain(t('ETIQUETTES_EN_TROP', { nombre: 21 }));
|
||||
expect(etiquettesDe(m, 'Ombrelle')).toHaveLength(20);
|
||||
expect(texteJournal(m)).toBe(avant);
|
||||
|
||||
// Passer à une autre personne vide le champ et son refus.
|
||||
await ouvrirFiche(m, 'Pervenche');
|
||||
expect(champEtiquettes(m).value).toBe('');
|
||||
expect(champEtiquettes(m).hasAttribute('aria-invalid')).toBe(false);
|
||||
expect(champEtiquettes(m).hasAttribute('aria-describedby')).toBe(false);
|
||||
expect(fiche(m).querySelector('[data-refus]')).toBeNull();
|
||||
});
|
||||
|
||||
test('un refus rendu après le passage à une autre personne ne se pose pas sur son champ', async () => {
|
||||
const m = await evenementOuvert({ personnes: TROIS_ETIQUETEES });
|
||||
// Une écriture retenue tient la file de la séance : l'ajout tapé
|
||||
// ensuite attend derrière elle.
|
||||
const liberer = retenirJournal(m);
|
||||
const tenue = m.seance.executer('changerEtiquettes', { id: idDe(m, 'Lacasse'), ajouter: 'finance' });
|
||||
await ouvrirFiche(m, 'Pervenche');
|
||||
await saisir(champEtiquettes(m), 'a\u{0009}b');
|
||||
champEtiquettes(m).focus();
|
||||
await touche('Enter');
|
||||
await rendu();
|
||||
await ouvrirFiche(m, 'Ombrelle');
|
||||
liberer();
|
||||
await tenue;
|
||||
await attendreSeance(m);
|
||||
expect(champEtiquettes(m).value).toBe('');
|
||||
expect(champEtiquettes(m).hasAttribute('aria-invalid')).toBe(false);
|
||||
expect(champEtiquettes(m).hasAttribute('aria-describedby')).toBe(false);
|
||||
expect(fiche(m).querySelector('[data-refus]')).toBeNull();
|
||||
});
|
||||
|
||||
test('une seconde Entrée pendant l’écriture n’envoie rien : un seul envoi, une entrée au journal, aucun refus', async () => {
|
||||
const m = await evenementOuvert({ personnes: TROIS_ETIQUETEES });
|
||||
await ouvrirFiche(m, 'Pervenche');
|
||||
const avant = entrees(m).length;
|
||||
const executer = vi.spyOn(m.seance, 'executer');
|
||||
const liberer = retenirJournal(m);
|
||||
await saisir(champEtiquettes(m), 'bénévole');
|
||||
champEtiquettes(m).focus();
|
||||
await touche('Enter');
|
||||
await rendu();
|
||||
expect(ajoutEtiquettes(m).getAttribute('aria-busy')).toBe('true');
|
||||
await touche('Enter');
|
||||
await rendu();
|
||||
liberer();
|
||||
await vi.waitFor(() => expect(etiquettesDe(m, 'Pervenche')).toEqual(['bénévole']));
|
||||
await attendreSeance(m);
|
||||
expect(executer.mock.calls.filter(([nom]) => nom === 'changerEtiquettes')).toHaveLength(1);
|
||||
expect(entrees(m)).toHaveLength(avant + 1);
|
||||
expect(fiche(m).querySelector('[data-refus]')).toBeNull();
|
||||
expect(champEtiquettes(m).hasAttribute('aria-invalid')).toBe(false);
|
||||
});
|
||||
|
||||
test('la légende des étiquettes suit celle des appartenances, chacune chiffrée exclues comprises, en pastilles neutres ; absente sans étiquette', async () => {
|
||||
const m = await evenementOuvert({ personnes: TROIS_ETIQUETEES });
|
||||
expect(legendeEtiquettes(m)).toBeNull();
|
||||
expect(legende(m)).not.toBeNull();
|
||||
await m.seance.executer('changerEtiquettes', { id: idDe(m, 'Ombrelle'), ajouter: 'finance, Montréal' });
|
||||
await m.seance.executer('changerEtiquettes', { id: idDe(m, 'Lacasse'), ajouter: 'finance' });
|
||||
await attendreSeance(m);
|
||||
const porteur = legendeEtiquettes(m);
|
||||
expect(porteur).not.toBeNull();
|
||||
expect(porteur.getAttribute('aria-label')).toBe(t('participants.legendeEtiquettes.titre'));
|
||||
// Sous celle des appartenances : sa voisine immédiate, dans le même
|
||||
// porteur des légendes.
|
||||
expect(legende(m).nextElementSibling).toBe(porteur);
|
||||
expect(porteur.parentElement).toBe(legende(m).parentElement);
|
||||
const lue = () => [...porteur.querySelectorAll('li')].map((li) => ({ texte: texteDe(li), fond: fondDe(pastilleDans(li)) }));
|
||||
const neutre = jeton('--couleur-neutre-fond');
|
||||
expect(lue()).toEqual([
|
||||
{ texte: t('participants.legende.element', { nom: 'finance', compte: 2 }), fond: neutre },
|
||||
{ texte: t('participants.legende.element', { nom: 'Montréal', compte: 1 }), fond: neutre },
|
||||
]);
|
||||
for (const li of porteur.querySelectorAll('li')) expect(pastilleDans(li).querySelector('svg')).not.toBeNull();
|
||||
// Un filtre ne la change pas.
|
||||
await saisir(section(m).querySelector('[data-champ="filtre"]'), 'Pervenche');
|
||||
expect(idsAffiches(m)).toHaveLength(1);
|
||||
expect(lue()).toHaveLength(2);
|
||||
// La dernière étiquette retirée, la légende part.
|
||||
await m.seance.executer('changerEtiquettes', { id: idDe(m, 'Ombrelle'), retirer: 'finance' });
|
||||
await m.seance.executer('changerEtiquettes', { id: idDe(m, 'Ombrelle'), retirer: 'Montréal' });
|
||||
await m.seance.executer('changerEtiquettes', { id: idDe(m, 'Lacasse'), retirer: 'finance' });
|
||||
await attendreSeance(m);
|
||||
expect(legendeEtiquettes(m)).toBeNull();
|
||||
});
|
||||
|
||||
test('en lecture : les pastilles restent ; « Ajouter » et « Retirer » sont refusés, chacun avec son remède, et rien ne s’écrit', async () => {
|
||||
const m = await evenementOuvert({ personnes: TROIS_ETIQUETEES });
|
||||
await m.seance.executer('changerEtiquettes', { id: idDe(m, 'Ombrelle'), ajouter: 'finance' });
|
||||
await m.seance.passerEnLecture();
|
||||
await ouvrirFiche(m, 'Ombrelle');
|
||||
const avant = texteJournal(m);
|
||||
expect(texteDe(pastilleDans(rubrique(m).querySelector('li')))).toBe('finance');
|
||||
|
||||
await ajouterParClavier(m, 'bénévole');
|
||||
const message = document.getElementById(champEtiquettes(m).getAttribute('aria-describedby'));
|
||||
expect(message.dataset.refus).toBe('MODE_LECTURE');
|
||||
expect(message.querySelector('[data-commande]')).not.toBeNull();
|
||||
|
||||
await cliquer(retrait(m, 'finance'));
|
||||
await attendreSeance(m);
|
||||
const refusRetrait = retrait(m, 'finance').parentElement.querySelector('[data-refus]');
|
||||
expect(refusRetrait.dataset.refus).toBe('MODE_LECTURE');
|
||||
expect(refusRetrait.querySelector('[data-commande]')).not.toBeNull();
|
||||
expect(etiquettesDe(m, 'Ombrelle')).toEqual(['finance']);
|
||||
expect(texteJournal(m)).toBe(avant);
|
||||
});
|
||||
|
||||
test('plan bloqué, en écriture : « Ajouter » et « Retirer » sont refusés avec « Débloquer » pour remède', async () => {
|
||||
const m = await evenementOuvert({ sieges: 4, tours: 2, tables: 1, personnes: TROIS_ETIQUETEES });
|
||||
await m.seance.executer('changerEtiquettes', { id: idDe(m, 'Ombrelle'), ajouter: 'finance' });
|
||||
await m.app.generation().generer({ graine: 1, nombre: 1, arret: 50, historique: 1000 });
|
||||
const [proposition] = m.seance.etat().charge.propositions;
|
||||
await m.seance.executer('retenirProposition', { id: proposition.id });
|
||||
await m.seance.executer('changerEtat', { etat: 'bloque' });
|
||||
await ouvrirFiche(m, 'Ombrelle');
|
||||
const avant = texteJournal(m);
|
||||
|
||||
await ajouterParClavier(m, 'bénévole');
|
||||
const message = document.getElementById(champEtiquettes(m).getAttribute('aria-describedby'));
|
||||
expect(message.querySelector('[data-commande="debloquer"]')).not.toBeNull();
|
||||
await cliquer(retrait(m, 'finance'));
|
||||
await attendreSeance(m);
|
||||
const refusRetrait = retrait(m, 'finance').parentElement.querySelector('[data-refus]');
|
||||
expect(refusRetrait.querySelector('[data-commande="debloquer"]')).not.toBeNull();
|
||||
expect(texteJournal(m)).toBe(avant);
|
||||
});
|
||||
});
|
||||
|
||||
describe('export et collage (§ 10.2)', () => {
|
||||
test('« Exporter la liste » remet les octets d’exporterParticipants ; réimportés dans un événement neuf, ils rendent les mêmes participants, exclu compris', async () => {
|
||||
const m = await evenementOuvert();
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
// La section des participants (§ 2.1 étape 3, § 4, § 10.2) : une carte
|
||||
// titrée, dont l'en-tête porte la ligne de comptes (comptesEntete,
|
||||
// entete.js) et l'export ; dessous, le formulaire d'ajout en bande de tête,
|
||||
// puis la barre du filtre, du groupement, du compte et de la légende des
|
||||
// appartenances, puis la grille ; à côté, la fiche ouverte, ou dessous
|
||||
// puis la barre du filtre, du groupement, du compte et des légendes des
|
||||
// appartenances et des étiquettes, puis la grille ; à côté, la fiche ouverte, ou dessous
|
||||
// quand la ligne ne tient pas les deux. Les lignes de la grille sont
|
||||
// celles de lignesGrille (grille.js) sur la liste de la séance, le tri et le filtre de la
|
||||
// section : regarder, trier et filtrer n'écrivent rien, en écriture comme
|
||||
|
|
@ -39,7 +39,12 @@
|
|||
// Les teintes d'appartenance (teintesAppartenances) se calculent une fois
|
||||
// sur la liste entière, exclues comprises, et la légende comme la grille
|
||||
// les lisent : un filtre ne repeint rien. La légende nomme chaque
|
||||
// appartenance avec le nombre de personnes qui la portent.
|
||||
// appartenance avec le nombre de personnes qui la portent. Dessous, quand
|
||||
// l'événement porte des étiquettes, la légende des étiquettes
|
||||
// (legendeEtiquettes, entete.js) nomme chacune avec le nombre de
|
||||
// personnes qui la portent, exclues comprises, en pastilles neutres à
|
||||
// icône : la teinte d'appartenance ne sert qu'à l'appartenance (§ 12.7).
|
||||
// Sans étiquette, elle n'est pas montrée.
|
||||
//
|
||||
// Supprimer une personne qui porte des réservations ouvre la fenêtre à
|
||||
// confirmer, qui les nomme ; sans réservation, ou quand la séance refuse
|
||||
|
|
@ -60,7 +65,7 @@
|
|||
import { statuts } from '../../application/places.js';
|
||||
import { conflitsDuPlacement } from '../../application/conflits_placement.js';
|
||||
import { conflitsDesGroupes, lignesDeConflit, reglesParPersonne } from './conflits.js';
|
||||
import { comptesEntete, legendeAppartenances } from './entete.js';
|
||||
import { comptesEntete, legendeAppartenances, legendeEtiquettes } from './entete.js';
|
||||
import {
|
||||
choisirGroupement,
|
||||
cleDeGroupement,
|
||||
|
|
@ -93,6 +98,7 @@
|
|||
const teintes = $derived(teintesAppartenances(participants));
|
||||
const comptes = $derived(comptesEntete(participants));
|
||||
const legende = $derived(legendeAppartenances(participants, teintes));
|
||||
const legendeDesEtiquettes = $derived(legendeEtiquettes(participants));
|
||||
const lignes = $derived(lignesGrille(participants, { tri, filtre }, cleNormalisee));
|
||||
const toutes = $derived(lignesGrille(participants, { tri, filtre: '' }, cleNormalisee));
|
||||
const parStatut = $derived(groupement.includes('statut') && charge !== null ? statuts(charge).parId : null);
|
||||
|
|
@ -179,12 +185,25 @@
|
|||
</button>
|
||||
{/if}
|
||||
<output>{t('participants.grille.compte', { total: participants.length, affiches: lignes.length })}</output>
|
||||
{#if legende.length > 0}
|
||||
<ul class="legende" aria-label={t('participants.legende.titre')}>
|
||||
{#each legende as { nom, rang, compte } (nom)}
|
||||
<li><Pastille sorte="appartenance" valeur={rang} libelle={t('participants.legende.element', { nom, compte })} /></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if legende.length > 0 || legendeDesEtiquettes.length > 0}
|
||||
<div class="legendes">
|
||||
{#if legende.length > 0}
|
||||
<ul class="legende" aria-label={t('participants.legende.titre')}>
|
||||
{#each legende as { nom, rang, compte } (nom)}
|
||||
<li><Pastille sorte="appartenance" valeur={rang} libelle={t('participants.legende.element', { nom, compte })} /></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if legendeDesEtiquettes.length > 0}
|
||||
<ul class="legende" data-legende-etiquettes aria-label={t('participants.legendeEtiquettes.titre')}>
|
||||
{#each legendeDesEtiquettes as { nom, compte } (nom)}
|
||||
<li>
|
||||
<Pastille sorte="statut" valeur="neutre" icone="etiquettes" libelle={t('participants.legende.element', { nom, compte })} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
@ -280,13 +299,25 @@
|
|||
font-size: var(--taille-texte-petit);
|
||||
}
|
||||
|
||||
/* Les légendes, poussées à droite tant que la ligne les tient : celle
|
||||
des étiquettes sous celle des appartenances. */
|
||||
.legendes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: center;
|
||||
align-items: flex-end;
|
||||
gap: var(--espace-2);
|
||||
min-width: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.legende {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-self: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--espace-1) var(--espace-2);
|
||||
min-width: 0;
|
||||
margin: 0 0 0 auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,14 @@
|
|||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// L'en-tête de la liste des participants, pur : la ligne de comptes de la
|
||||
// carte et la légende des appartenances. Les deux se lisent sur la liste
|
||||
// entière, exclues comprises — la population de l'attribution des teintes
|
||||
// (teintesAppartenances, appartenances.js) — : un filtre ne change ni un
|
||||
// compte ni une teinte. Aucune fonction ne modifie ce qu'elle reçoit.
|
||||
// carte, la légende des appartenances et celle des étiquettes. Toutes se
|
||||
// lisent sur la liste entière, exclues comprises — la population de
|
||||
// l'attribution des teintes (teintesAppartenances, appartenances.js) — : un
|
||||
// filtre ne change ni un compte ni une teinte. Aucune fonction ne modifie ce
|
||||
// qu'elle reçoit.
|
||||
|
||||
import { cleNormalisee } from '../../application/csv.js';
|
||||
import { comparerPointsDeCode } from '../../application/planche.js';
|
||||
|
||||
/**
|
||||
* La ligne de comptes de la carte des participants.
|
||||
|
|
@ -49,3 +53,27 @@ export function legendeAppartenances(participants, teintes) {
|
|||
}
|
||||
return legende;
|
||||
}
|
||||
|
||||
/**
|
||||
* La légende des étiquettes (§ 10.2) : une entrée par étiquette portée,
|
||||
* avec le nombre de personnes qui la portent, exclues comprises. Deux
|
||||
* orthographes sont deux étiquettes, comme pour le moteur (§ 4.5) ; elles
|
||||
* se rangent comme les appartenances : par clé normalisée (§ 10.1),
|
||||
* comparée point de code par point de code, puis par leur chaîne. L'ordre
|
||||
* ne dépend donc pas de celui de la liste. Vide quand personne ne porte
|
||||
* d'étiquette : la section ne montre alors aucune légende.
|
||||
*
|
||||
* @param {Array<{etiquettes?: string[]}>} participants tous ceux de
|
||||
* l'événement
|
||||
* @returns {Array<{nom: string, compte: number}>}
|
||||
*/
|
||||
export function legendeEtiquettes(participants) {
|
||||
const comptes = new Map();
|
||||
for (const { etiquettes = [] } of participants) {
|
||||
for (const motCle of new Set(etiquettes)) comptes.set(motCle, (comptes.get(motCle) ?? 0) + 1);
|
||||
}
|
||||
return [...comptes]
|
||||
.map(([nom, compte]) => ({ nom, compte, cle: cleNormalisee(nom) }))
|
||||
.sort((a, b) => comparerPointsDeCode(a.cle, b.cle) || comparerPointsDeCode(a.nom, b.nom))
|
||||
.map(({ nom, compte }) => ({ nom, compte }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// L'en-tête de la liste des participants : la ligne de comptes de la carte
|
||||
// et la légende des appartenances, chacune lue sur la liste entière, exclues
|
||||
// comprises, et le texte que les libellés en composent. Les noms d'épreuve
|
||||
// sont inventés.
|
||||
// L'en-tête de la liste des participants : la ligne de comptes de la carte,
|
||||
// la légende des appartenances et celle des étiquettes, chacune lue sur la
|
||||
// liste entière, exclues comprises, et le texte que les libellés en
|
||||
// composent. Les noms d'épreuve sont inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { RANGS_APPARTENANCE, teintesAppartenances } from '../appartenances.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { comptesEntete, legendeAppartenances } from './entete.js';
|
||||
import { comptesEntete, legendeAppartenances, legendeEtiquettes } from './entete.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
|
||||
|
|
@ -105,3 +105,50 @@ describe('legendeAppartenances : la légende en pastilles, avec leur compte', ()
|
|||
assert.equal(t('participants.legende.titre'), 'Appartenances');
|
||||
});
|
||||
});
|
||||
|
||||
// Une personne qui porte des étiquettes ; sans liste, le champ est absent,
|
||||
// comme dans la charge.
|
||||
const etiquetee = (id, etiquettes, exclu = false) => {
|
||||
const p = personne(id, null, exclu);
|
||||
return etiquettes === undefined ? p : { ...p, etiquettes };
|
||||
};
|
||||
|
||||
describe('legendeEtiquettes : la légende des étiquettes, avec leur compte (§ 10.2)', () => {
|
||||
test('chaque étiquette portée, par sa clé normalisée puis par points de code, avec le nombre de personnes qui la portent, exclues comprises', () => {
|
||||
const liste = [
|
||||
etiquetee(1, ['Montréal', 'finance']),
|
||||
etiquetee(2, ['finance']),
|
||||
etiquetee(3, ['bénévole', 'finance'], true),
|
||||
etiquetee(4, undefined),
|
||||
etiquetee(5, ['Finance']),
|
||||
];
|
||||
assert.deepEqual(legendeEtiquettes(liste), [
|
||||
{ nom: 'bénévole', compte: 1 },
|
||||
{ nom: 'Finance', compte: 1 },
|
||||
{ nom: 'finance', compte: 3 },
|
||||
{ nom: 'Montréal', compte: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('sans étiquette portée, une légende vide : la section ne la montre pas', () => {
|
||||
assert.deepEqual(legendeEtiquettes([]), []);
|
||||
assert.deepEqual(legendeEtiquettes([etiquetee(1, undefined), etiquetee(2, undefined, true)]), []);
|
||||
});
|
||||
|
||||
test('une étiquette égale au nom d’une appartenance reste une étiquette, et l’appartenance ne s’y compte pas', () => {
|
||||
const liste = [{ ...etiquetee(1, ['Atelier Pivoine']), appartenance: 'Atelier Pivoine' }, personne(2, 'Atelier Pivoine')];
|
||||
assert.deepEqual(legendeEtiquettes(liste), [{ nom: 'Atelier Pivoine', compte: 1 }]);
|
||||
assert.deepEqual(legendeAppartenances(liste, teintesAppartenances(liste)), [{ nom: 'Atelier Pivoine', rang: 1, compte: 2 }]);
|
||||
});
|
||||
|
||||
test('l’ordre ne dépend pas de l’ordre de la liste : un filtre ou un tri ne le change pas', () => {
|
||||
const liste = [etiquetee(1, ['ressources humaines']), etiquetee(2, ['bénévole', 'Montréal']), etiquetee(3, ['finance'])];
|
||||
assert.deepEqual(legendeEtiquettes([...liste].reverse()), legendeEtiquettes(liste));
|
||||
});
|
||||
|
||||
test('le titre de la légende des étiquettes ; chaque élément, le nom puis le compte, comme les appartenances', () => {
|
||||
assert.equal(t('participants.legendeEtiquettes.titre'), 'Étiquettes');
|
||||
assert.notEqual(t('participants.legendeEtiquettes.titre'), t('participants.legende.titre'));
|
||||
assert.equal(t('participants.legende.element', { nom: 'finance', compte: 3 }), 'finance \u{00B7} 3');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
72
src/interface/participants/etiquettes.js
Normal 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);
|
||||
}
|
||||
108
src/interface/participants/etiquettes.test.js
Normal 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 »');
|
||||
});
|
||||
});
|
||||
|
|
@ -42,6 +42,8 @@ const DESSINS = [
|
|||
['chevron', ['M9 6l6 6-6 6']],
|
||||
['points', [cercle(5, 12, 1.5), cercle(12, 12, 1.5), cercle(19, 12, 1.5)]],
|
||||
['demarrer', ['M8 5l11 7-11 7z']],
|
||||
// Une étiquette (§ 4.5) : l'étiquette à œillet, pointe en bas à droite.
|
||||
['etiquettes', ['M3 3h8l10 10-8 8L3 11z', cercle(7.5, 7.5, 1.5)]],
|
||||
];
|
||||
|
||||
/** Les noms des icônes, dans l'ordre du module. */
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ describe('icônes des primitives', () => {
|
|||
assert.deepEqual(NOMS_ICONES, [
|
||||
'crayon', 'listePoints', 'coche', 'cadenas', 'liste', 'importer', 'exporter', 'ajouter', 'chercher',
|
||||
'calendrier', 'information', 'triangle', 'interdit', 'dossier', 'oeil', 'stylo', 'defaire', 'imprimer',
|
||||
'etoile', 'croix', 'chevron', 'points', 'demarrer',
|
||||
'etoile', 'croix', 'chevron', 'points', 'demarrer', 'etiquettes',
|
||||
]);
|
||||
assert.ok(Object.isFrozen(ICONES) && Object.isFrozen(NOMS_ICONES));
|
||||
assert.deepEqual(Object.keys(ICONES).sort(), [...NOMS_ICONES].sort());
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
import { flushSync } from 'svelte';
|
||||
import { appliquerCommande } from '../../application/commandes.js';
|
||||
import { configurationDe } from '../../application/configuration.js';
|
||||
import { creerFormat } from '../../application/format.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { nomAffiche } from '../../application/personnes.js';
|
||||
|
|
@ -40,7 +41,7 @@ import { fixerCadre, pointer, touche } from '../../../test/navigateur/entrees.js
|
|||
import { monterApp } from '../../../test/navigateur/montage.js';
|
||||
import { composantes, contraste, couleurTexte, fondPeintSous, sousTheme } from '../../../test/navigateur/rendu.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { GROUPES_COLONNES, evaluation, tableau } from './modele.js';
|
||||
import { evaluation, tableau } from './modele.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const format = creerFormat(t);
|
||||
|
|
@ -1095,7 +1096,10 @@ describe('l’habillage du tableau des propositions', () => {
|
|||
const thead = section(m).querySelector('thead');
|
||||
expect(thead.rows.length).toBe(2);
|
||||
const groupes = [...thead.rows[0].querySelectorAll('th')];
|
||||
const attendus = GROUPES_COLONNES.filter(({ cle }) => cle !== null);
|
||||
// Sans objectif ni case des animateurs, les cinq groupes du tableau.
|
||||
const segments = tableau(evaluation(charge(m)).table).groupes;
|
||||
const attendus = segments.filter(({ cle }) => cle !== null);
|
||||
expect(attendus.length).toBe(5);
|
||||
expect(groupes.map(texteDe)).toEqual(attendus.map(({ cle }) => t(cle)));
|
||||
expect(thead.rows[0].querySelectorAll('[data-colonne]').length).toBe(0);
|
||||
expect([...thead.rows[1].querySelectorAll('th[data-colonne]')].map((th) => th.dataset.colonne)).toEqual(NOMS_DES_COLONNES);
|
||||
|
|
@ -1110,7 +1114,7 @@ describe('l’habillage du tableau des propositions', () => {
|
|||
expect(g.bottom).toBeLessThanOrEqual(premiere.top + 1);
|
||||
}
|
||||
const colgroups = [...section(m).querySelectorAll('table > colgroup')];
|
||||
expect(colgroups.map((colgroup) => colgroup.span)).toEqual(GROUPES_COLONNES.map(({ etendue }) => etendue));
|
||||
expect(colgroups.map((colgroup) => colgroup.span)).toEqual(segments.map(({ etendue }) => etendue));
|
||||
});
|
||||
|
||||
test('les nombres, en-têtes compris, s’alignent à droite en chiffres tabulaires', async () => {
|
||||
|
|
@ -1171,6 +1175,184 @@ describe('l’habillage du tableau des propositions', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// --- Le sixième groupe et la phrase par paliers (§ 5.7, A9, A11) ------------------
|
||||
|
||||
// La petite démonstration au conflit inévitable et les trois plans de
|
||||
// PLANS_CONFLIT, les personnes 1 à 6 « finance », la première table attendant
|
||||
// « finance » pour la soirée ; les réglages donnés s'ajoutent à ceux de la
|
||||
// charge.
|
||||
function petiteAObjectifs(reglages = {}) {
|
||||
const chargeLue = avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT);
|
||||
for (const participant of chargeLue.participants) if (participant.id <= 6) participant.etiquettes = ['finance'];
|
||||
const [premiere] = [...chargeLue.tables].sort((a, b) => a.id - b.id);
|
||||
premiere.objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }];
|
||||
Object.assign(chargeLue.reglages, reglages);
|
||||
return chargeLue;
|
||||
}
|
||||
|
||||
describe('le sixième groupe et la phrase par paliers (§ 5.7, A9, A11)', () => {
|
||||
// Le pointeur réel part du coin du cadre, hors de la phrase et du tableau.
|
||||
beforeEach(async () => {
|
||||
await pointer('bouger', 0, 0);
|
||||
});
|
||||
|
||||
const enTetesDesColonnes = (m) => [...section(m).querySelector('thead').rows[1].querySelectorAll('th[data-colonne]')].map((th) => th.dataset.colonne);
|
||||
const enTeteDuGroupe = (m) =>
|
||||
[...section(m).querySelector('thead').rows[0].querySelectorAll('th')].find((th) => texteDe(th) === t('propositions.groupe.objectifs')) ?? null;
|
||||
// Les colonnes du défaut, celles du groupe des objectifs et des animateurs
|
||||
// insérées après l'écart au plafond a priori, avant le certificat.
|
||||
const avecSixieme = (...noms) => [...NOMS_DES_COLONNES.slice(0, 18), ...noms, ...NOMS_DES_COLONNES.slice(18)];
|
||||
|
||||
test('« chacun au moins une fois » : le groupe « Objectifs des tables et animateurs » couvre ses trois colonnes, chiffres du moteur ; la phrase nomme « personnes jamais servies, puis tours hors objectif »', async () => {
|
||||
const chargeLue = executer(petiteAObjectifs({ modeObjectifs: 'chacun' }), 'retenirProposition', { id: 3 });
|
||||
const m = await ouvrir(chargeLue);
|
||||
expect(enTetesDesColonnes(m)).toEqual(avecSixieme('jamaisServies', 'toursHorsObjectif', 'toursSelonObjectif'));
|
||||
const groupe = enTeteDuGroupe(m);
|
||||
expect(groupe).not.toBeNull();
|
||||
expect(groupe.getAttribute('scope')).toBe('colgroup');
|
||||
expect(groupe.colSpan).toBe(3);
|
||||
const g = groupe.getBoundingClientRect();
|
||||
const thead = section(m).querySelector('thead');
|
||||
expect(Math.abs(g.left - thead.querySelector('th[data-colonne="jamaisServies"]').getBoundingClientRect().left)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(g.right - thead.querySelector('th[data-colonne="toursSelonObjectif"]').getBoundingClientRect().right)).toBeLessThanOrEqual(1);
|
||||
const instance = normaliser(configurationDe(chargeLue));
|
||||
for (const proposition of chargeLue.propositions) {
|
||||
const { objectifs } = mesurer(instance, indexerPlan(instance, planDepuisFichier(proposition)));
|
||||
const lues = cellules(ligne(m, proposition.id));
|
||||
expect(lues.get('jamaisServies')).toBe(format.nombre(objectifs.jamaisServies));
|
||||
expect(lues.get('toursHorsObjectif')).toBe(format.nombre(objectifs.horsObjectif));
|
||||
expect(lues.get('toursSelonObjectif')).toBe(format.nombre(objectifs.agregat.tous.min));
|
||||
}
|
||||
expect(cellules(ligneRetenu(m)).has('toursHorsObjectif')).toBe(true);
|
||||
expect(section(m).querySelector('[data-ordre] p').textContent).toBe(
|
||||
"Ordre du classement, chaque critère au plus faible d'abord : écart au plafond a priori, puis excédent de collisions, " +
|
||||
'puis collisions cumulées, puis personnes jamais servies, puis tours hors objectif, puis rencontres répétées, ' +
|
||||
"puis redondance d'appartenance, puis l'identifiant croissant.",
|
||||
);
|
||||
});
|
||||
|
||||
test('les deux noms du critère des objectifs, au clavier : chacun surligne sa colonne seule et ouvre son explication, qu’il est seul à désigner', async () => {
|
||||
const m = await ouvrir(executer(petiteAObjectifs({ modeObjectifs: 'chacun' }), 'retenirProposition', { id: 3 }));
|
||||
const noms = [...section(m).querySelectorAll('[data-ordre] [data-critere="toursHorsObjectif"]')];
|
||||
expect(noms.map(texteDe)).toEqual(['personnes jamais servies', 'tours hors objectif']);
|
||||
for (const [nom, nomColonne] of [
|
||||
[noms[0], 'jamaisServies'],
|
||||
[noms[1], 'toursHorsObjectif'],
|
||||
]) {
|
||||
expect(nom.tabIndex).toBe(0);
|
||||
nom.focus();
|
||||
expect(colonne(m, nomColonne).length).toBe(5);
|
||||
await attendre(() => expect(surlignees(m), nomColonne).toEqual(colonne(m, nomColonne)));
|
||||
await attendre(() => exigerInfobulle(nom, explicationDeColonne(nomColonne)));
|
||||
nom.blur();
|
||||
await attendre(() => expect(surlignees(m)).toEqual([]));
|
||||
}
|
||||
});
|
||||
|
||||
test('« nouvelle table » cochée seule : le nom « retours » surligne la colonne des retours choisis, jamais celle des rencontres répétées', async () => {
|
||||
const chargeLue = avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT);
|
||||
for (const cle of ['separerAppartenances', 'nouveauxVoisins', 'varierAppartenances']) chargeLue.reglages[cle] = false;
|
||||
const m = await ouvrir(chargeLue);
|
||||
expect(texteDe(section(m).querySelector('[data-ordre]'))).toBe(
|
||||
"Ordre du classement, chaque critère au plus faible d'abord : écart au plafond a priori, puis retours, puis l'identifiant croissant.",
|
||||
);
|
||||
const nom = section(m).querySelector('[data-ordre] [data-critere="rencontresRepetees"]');
|
||||
expect(texteDe(nom)).toBe('retours');
|
||||
const { x, y } = await centre(nom);
|
||||
await pointer('bouger', x, y);
|
||||
await attendre(() => expect(surlignees(m)).toEqual(colonne(m, 'retoursChoisis')));
|
||||
for (const cellule of colonne(m, 'rencontresRepetees')) expect(cellule.dataset.marque).toBeUndefined();
|
||||
await attendre(() => exigerInfobulle(nom, explicationDeColonne('retoursChoisis')));
|
||||
});
|
||||
|
||||
test('un palier partagé : « ensemble, », chaque nom suivi de son poids ; seuls les noms se focalisent, les poids restent du texte', async () => {
|
||||
const chargeLue = avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT);
|
||||
const e = (critere, poids = 1) => ({ critere, poids });
|
||||
chargeLue.reglages.priorites = [
|
||||
[e('ecartAuPlafondAPrioriMax')],
|
||||
[e('excedentCollisions', 3), e('redondance', 2)],
|
||||
[e('collisionsCumulees')],
|
||||
[e('toursHorsObjectif')],
|
||||
[e('rencontresRepetees')],
|
||||
[e('animateursNonRencontres')],
|
||||
];
|
||||
const m = await ouvrir(chargeLue);
|
||||
expect(section(m).querySelector('[data-ordre] p').textContent).toBe(
|
||||
"Ordre du classement, chaque critère au plus faible d'abord : écart au plafond a priori, puis ensemble, " +
|
||||
"excédent de collisions (poids 3), redondance d'appartenance (poids 2), puis collisions cumulées, " +
|
||||
"puis rencontres répétées, puis l'identifiant croissant.",
|
||||
);
|
||||
const noms = nomsDesignants(m);
|
||||
expect(noms.map((nom) => nom.dataset.critere)).toEqual([
|
||||
'ecartAuPlafondAPrioriMax',
|
||||
'excedentCollisions',
|
||||
'redondance',
|
||||
'collisionsCumulees',
|
||||
'rencontresRepetees',
|
||||
'identifiant',
|
||||
]);
|
||||
for (const nom of noms) expect(texteDe(nom)).not.toMatch(/poids|ensemble/);
|
||||
const redondance = section(m).querySelector('[data-ordre] [data-critere="redondance"]');
|
||||
redondance.focus();
|
||||
await attendre(() => expect(surlignees(m)).toEqual(colonne(m, 'totalRedondance')));
|
||||
});
|
||||
|
||||
test('chaque en-tête du sixième groupe se focalise, ouvre son explication, aligne ses nombres à droite en chiffres tabulaires ; ses textes à 4,5:1 dans les deux thèmes', async () => {
|
||||
const m = await ouvrir(petiteAObjectifs({ modeObjectifs: 'minimum' }));
|
||||
const noms = ['horsObjectifMax', 'toursHorsObjectif', 'toursSelonObjectif'];
|
||||
expect(enTetesDesColonnes(m)).toEqual(avecSixieme(...noms));
|
||||
for (const nom of noms) {
|
||||
const th = enTetes(m).find((candidat) => candidat.dataset.colonne === nom);
|
||||
expect(th.tabIndex).toBe(0);
|
||||
th.focus();
|
||||
await attendre(() => exigerInfobulle(th, explicationDeColonne(nom)));
|
||||
for (const cellule of colonne(m, nom)) {
|
||||
const style = getComputedStyle(cellule);
|
||||
expect(style.textAlign, nom).toBe('right');
|
||||
expect(style.fontVariantNumeric, nom).toContain('tabular-nums');
|
||||
}
|
||||
th.blur();
|
||||
}
|
||||
await attendre(() => exigerFermee());
|
||||
for (const theme of ['clair', 'sombre']) {
|
||||
await sousTheme(theme, async () => {
|
||||
flushSync();
|
||||
for (const element of [enTeteDuGroupe(m), ...noms.flatMap((nom) => colonne(m, nom))]) {
|
||||
element.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
const [fond] = fondPeintSous(element);
|
||||
expect(contraste(couleurTexte(element), fond), `${theme} ${element.dataset.colonne ?? 'groupe'}`).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('la case des animateurs cochée sur la grande : le groupe couvre le minimum des animateurs rencontrés et les animateurs non rencontrés, chiffres du moteur', async () => {
|
||||
const depart = structuredClone(chargeDemo('grande'));
|
||||
depart.reglages.rencontrerAnimateurs = true;
|
||||
const chargeLue = generee(depart, { graine: 1, nombre: 1, arret: 2000 });
|
||||
const m = await ouvrir(chargeLue);
|
||||
expect(enTetesDesColonnes(m)).toEqual(avecSixieme('animateursRencontres', 'animateursNonRencontres'));
|
||||
expect(enTeteDuGroupe(m).colSpan).toBe(2);
|
||||
const instance = normaliser(configurationDe(chargeLue));
|
||||
const { animateurs } = mesurer(instance, indexerPlan(instance, planDepuisFichier(chargeLue.propositions[0])));
|
||||
const lues = cellules(ligne(m, 1));
|
||||
expect(lues.get('animateursRencontres')).toBe(format.nombre(animateurs.agregat.tous.min));
|
||||
expect(lues.get('animateursNonRencontres')).toBe(format.nombre(animateurs.nonRencontres));
|
||||
const nom = section(m).querySelector('[data-ordre] [data-critere="animateursNonRencontres"]');
|
||||
expect(texteDe(nom)).toBe('animateurs non rencontrés');
|
||||
nom.focus();
|
||||
await attendre(() => expect(surlignees(m)).toEqual(colonne(m, 'animateursNonRencontres')));
|
||||
});
|
||||
|
||||
test('le critère des objectifs décoché : ni groupe ni colonne des objectifs, et la phrase ne le nomme pas', async () => {
|
||||
const m = await ouvrir(petiteAObjectifs({ respecterObjectifs: false, modeObjectifs: 'chacun' }));
|
||||
expect(enTetesDesColonnes(m)).toEqual(NOMS_DES_COLONNES);
|
||||
expect(enTeteDuGroupe(m)).toBeNull();
|
||||
expect(section(m).querySelector('[data-ordre] [data-critere="toursHorsObjectif"]')).toBeNull();
|
||||
expect(texteDe(section(m).querySelector('[data-ordre]'))).toBe(t('propositions.ordre.criteres', { criteres: [...CRITERES] }));
|
||||
});
|
||||
});
|
||||
|
||||
// --- Les pages du classement (§ 5.7, § 2.1 étapes 9 et 10, § 8.4) ---------------
|
||||
|
||||
// La grande démonstration et 237 propositions (graine 7, arrêt 50) : brute,
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@
|
|||
// « Retenir » et « Effacer », le refus posé à côté du bouton.
|
||||
//
|
||||
// La table d'évaluation se lit dans la charge de l'état observable, par
|
||||
// evaluation (modele.js) : une seule mesure, celle d'evaluer (evaluation.js), refaite
|
||||
// à chaque charge écrite ; les indicateurs du retenu suivent donc
|
||||
// l'ajustement (§ 2.1 étape 11).
|
||||
// evaluation (modele.js) : une seule mesure, celle d'evaluer
|
||||
// (evaluation.js), refaite à chaque charge écrite ; les indicateurs du
|
||||
// retenu suivent donc l'ajustement (§ 2.1 étape 11).
|
||||
//
|
||||
// « Voir sur le plan » demande la proposition au plan par l'état de
|
||||
// navigation, sans écriture, et ouvre la section du plan ; voir(null) y
|
||||
|
|
@ -24,20 +24,23 @@
|
|||
// aucune saisie — chaque proposition se régénère de sa graine, et le geste
|
||||
// se défait — : il s'exécute sans fenêtre.
|
||||
//
|
||||
// La phrase de l'ordre se met bout à bout, morceau par morceau (modele.js) ;
|
||||
// chaque nom de critère, et l'identifiant croissant, est un élément
|
||||
// focalisable qui désigne sa colonne. Le survoler ou le focaliser surligne
|
||||
// cette colonne dans le tableau. Le survol et le focus se tiennent à part,
|
||||
// et surlignee (modele.js) choisit : le nom survolé, sinon celui qui a le
|
||||
// focus. Quitter un nom — pointeur sorti, focus perdu — n'efface que l'état
|
||||
// qu'il tenait, et laisse l'autre.
|
||||
// La phrase de l'ordre se met bout à bout, morceau par morceau (modele.js) :
|
||||
// les paliers appliqués, chaque nom celui de la valeur que son palier
|
||||
// applique — deux noms pour un critère qui en classe deux. Chaque nom, et
|
||||
// l'identifiant croissant, est un élément focalisable qui porte le critère
|
||||
// qui le classe (data-critere) et désigne la colonne de sa valeur. Le
|
||||
// survoler ou le focaliser surligne cette colonne dans le tableau. Le
|
||||
// survol et le focus se tiennent à part, et surlignee (modele.js) choisit :
|
||||
// le nom survolé, sinon celui qui a le focus. Quitter un nom — pointeur
|
||||
// sorti, focus perdu — n'efface que l'état qu'il tenait, et laisse l'autre.
|
||||
//
|
||||
// Chaque nom de critère et chaque en-tête de colonne s'expliquent dans
|
||||
// l'infobulle de la section, une seule (Infobulle, primitive) : le survol
|
||||
// ou le focus l'ouvre sur le terme, le quitter la referme quand elle est
|
||||
// encore la sienne, Échap aussi. Un critère et sa colonne partagent leur
|
||||
// explication (explicationDeColonne, modele.js). Le terme expliqué porte
|
||||
// aria-describedby vers elle, et lui seul.
|
||||
// Chaque nom de la phrase, celui d'une valeur appliquée, et chaque en-tête
|
||||
// de colonne s'expliquent dans l'infobulle de la section, une seule
|
||||
// (Infobulle, primitive) : le survol ou le focus l'ouvre sur le terme, le
|
||||
// quitter la referme quand elle est encore la sienne, Échap aussi. Une
|
||||
// valeur appliquée et sa colonne partagent leur explication
|
||||
// (explicationDeColonne, colonneDeValeur, modele.js). Le terme expliqué
|
||||
// porte aria-describedby vers elle, et lui seul.
|
||||
//
|
||||
// Au-delà de TAILLE_PAGE propositions (modele.js), le tableau se lit page
|
||||
// à page : pagine découpe le classement unique, sans le retrier, et la
|
||||
|
|
@ -163,10 +166,10 @@
|
|||
const surlignee = $derived(colonneSurlignee(survolee, focalisee));
|
||||
|
||||
// Le terme expliqué : {ancre, terme, cle, details} — son élément, sa
|
||||
// désignation, 'critere:<critère>' pour un nom de la phrase ou
|
||||
// 'colonne:<nom>' pour un en-tête, et la clé de son explication — ; null
|
||||
// sans infobulle. Ouvrir sur un terme remplace l'explication d'un autre ;
|
||||
// quitter un terme ne ferme que la sienne.
|
||||
// désignation, 'valeur:<valeur>' pour un nom de la phrase, où chaque
|
||||
// valeur paraît une fois, ou 'colonne:<nom>' pour un en-tête, et la clé
|
||||
// de son explication — ; null sans infobulle. Ouvrir sur un terme remplace
|
||||
// l'explication d'un autre ; quitter un terme ne ferme que la sienne.
|
||||
const uid = $props.id();
|
||||
const idExplication = `${uid}-explication`;
|
||||
let expliquee = $state(null);
|
||||
|
|
@ -181,15 +184,15 @@
|
|||
const decrit = (terme) => (expliquee?.terme === terme ? idExplication : undefined);
|
||||
|
||||
// L'entrée sur un nom, par le pointeur ou par le focus, et sa sortie.
|
||||
function entrer(evenement, { critere, colonne }, parFocus) {
|
||||
function entrer(evenement, { valeur, colonne }, parFocus) {
|
||||
if (parFocus) focalisee = colonne;
|
||||
else survolee = colonne;
|
||||
expliquer(evenement, `critere:${critere}`, colonne);
|
||||
expliquer(evenement, `valeur:${valeur}`, colonne);
|
||||
}
|
||||
function sortir({ critere }, parFocus) {
|
||||
function sortir({ valeur }, parFocus) {
|
||||
if (parFocus) focalisee = null;
|
||||
else survolee = null;
|
||||
taire(`critere:${critere}`);
|
||||
taire(`valeur:${valeur}`);
|
||||
}
|
||||
|
||||
const effacer = () => seance.executer('effacerPropositions', {});
|
||||
|
|
@ -254,7 +257,7 @@
|
|||
class:actif={surlignee === morceau.colonne}
|
||||
data-critere={morceau.critere}
|
||||
tabindex="0"
|
||||
aria-describedby={decrit(`critere:${morceau.critere}`)}
|
||||
aria-describedby={decrit(`valeur:${morceau.valeur}`)}
|
||||
onmouseenter={(evenement) => entrer(evenement, morceau, false)}
|
||||
onmouseleave={() => sortir(morceau, false)}
|
||||
onfocus={(evenement) => entrer(evenement, morceau, true)}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@
|
|||
// Les violations d'une ligne comparable — retenu compris — se lisent sous
|
||||
// son identifiant.
|
||||
//
|
||||
// vue : ce que rend tableau(), libellés et étendues compris, ses lignes
|
||||
// et ses lignes hors classement réduites à celles de la page montrée
|
||||
// vue : ce que rend tableau(), libellés et étendues compris — ses colonnes
|
||||
// et ses groupes, ceux que porte la table, le sixième groupe selon les
|
||||
// critères qui comptent —, ses lignes et ses lignes hors classement
|
||||
// réduites à celles de la page montrée
|
||||
// (pagine, modele.js) ; chaque ligne garde son rang global. nommer(id) :
|
||||
// le nom affiché d'une personne, null pour id null ou pour une personne que
|
||||
// la charge n'a plus. voir(id) et
|
||||
|
|
@ -26,11 +28,12 @@
|
|||
//
|
||||
// Chaque en-tête explique sa colonne dans l'infobulle de la section : il se
|
||||
// focalise au clavier (tabindex 0), et son survol ou son focus appelle
|
||||
// expliquer(evenement, 'colonne:<nom>', nom), le quitter taire('colonne:<nom>').
|
||||
// expliquer(evenement, 'colonne:<nom>', nom), le quitter
|
||||
// taire('colonne:<nom>').
|
||||
// decrit('colonne:<nom>') rend l'identifiant de l'infobulle quand elle
|
||||
// explique cet en-tête, pour son aria-describedby, undefined sinon.
|
||||
//
|
||||
// Deux rangées d'en-têtes : celle des groupes (GROUPES_COLONNES, modele.js),
|
||||
// Deux rangées d'en-têtes : celle des groupes (vue.groupes, modele.js),
|
||||
// chacun en scope colgroup au-dessus de ses colonnes, une cellule vide au-
|
||||
// dessus d'une colonne hors groupe ; puis celle des colonnes, seule à
|
||||
// porter data-colonne et les explications. Les colonnes de nombres
|
||||
|
|
@ -50,7 +53,7 @@
|
|||
import Commande from '../primitives/Commande.svelte';
|
||||
import Icone from '../primitives/Icone.svelte';
|
||||
import Pastille from '../primitives/Pastille.svelte';
|
||||
import { GROUPES_COLONNES, texteCellule } from './modele.js';
|
||||
import { texteCellule } from './modele.js';
|
||||
|
||||
let { vue, surlignee = null, nommer, voir, retenir, expliquer, taire, decrit } = $props();
|
||||
|
||||
|
|
@ -123,12 +126,12 @@
|
|||
|
||||
<div class="defilement">
|
||||
<table aria-label={t('propositions.tableau.titre')}>
|
||||
{#each GROUPES_COLONNES as segment, rang (rang)}
|
||||
{#each vue.groupes as segment, rang (rang)}
|
||||
<colgroup span={segment.etendue}></colgroup>
|
||||
{/each}
|
||||
<thead>
|
||||
<tr class="groupes">
|
||||
{#each GROUPES_COLONNES as segment, rang (rang)}
|
||||
{#each vue.groupes as segment, rang (rang)}
|
||||
{#if segment.cle === null}
|
||||
<td></td>
|
||||
{:else}
|
||||
|
|
|
|||
|
|
@ -5,14 +5,14 @@
|
|||
// § 12.6, § 12.9), pur : SectionPropositions.svelte et
|
||||
// TableauPropositions.svelte le câblent.
|
||||
//
|
||||
// Le tableau lit la table d'évaluation d'evaluer (evaluation.js), la seule mesure : chaque
|
||||
// cellule porte la valeur que la table rend, telle quelle, et sa clé de mise
|
||||
// en forme ; aucune arithmétique ne s'y fait, aucun plafond ne s'y recompose.
|
||||
// Des rencontres, il ne porte que le minimum, sur les trois populations du
|
||||
// § 5.4 — tous, mobiles, ancrés —, en tête des colonnes de mesure ; la moyenne
|
||||
// n'y entre pas. Le plafond réalisé, individuel, se lit par le manque, dont
|
||||
// les cellules sont celles de troisChiffres. Les imposés se disent à part, sur
|
||||
// chaque ligne, jamais ajoutés aux choisis.
|
||||
// Le tableau lit la table d'évaluation d'evaluer (evaluation.js), la seule
|
||||
// mesure : chaque cellule porte la valeur que la table rend, telle quelle, et
|
||||
// sa clé de mise en forme ; aucune arithmétique ne s'y fait, aucun plafond
|
||||
// ne s'y recompose. Des rencontres, il ne porte que le minimum, sur les trois
|
||||
// populations du § 5.4 — tous, mobiles, ancrés —, en tête des colonnes de
|
||||
// mesure ; la moyenne n'y entre pas. Le plafond réalisé, individuel, se lit
|
||||
// par le manque, dont les cellules sont celles de troisChiffres. Les imposés
|
||||
// se disent à part, sur chaque ligne, jamais ajoutés aux choisis.
|
||||
//
|
||||
// Chaque minimum affiché désigne la personne la plus mal servie (§ 12.1) :
|
||||
// le moins de rencontres, d'appartenances vues, de diversité. La redondance
|
||||
|
|
@ -20,6 +20,12 @@
|
|||
// son minimum désignerait la personne la mieux servie : elle se lit par sa
|
||||
// somme Σ r(p), la grandeur que la recherche et le classement lisent.
|
||||
//
|
||||
// Les objectifs des tables et les animateurs (A9, A11) forment un sixième
|
||||
// groupe de colonnes, présentes chacune quand son critère compte — coché et
|
||||
// avec un objet, tel que l'état des critères de la table le dit — : le
|
||||
// tableau ne recalcule ni « sans objet » ni le mode, qu'il lit dans les
|
||||
// valeurs que le critère des objectifs classe.
|
||||
//
|
||||
// Une cellule vaut { colonne, valeur, cle, decimales } : cle null, la valeur
|
||||
// est un nombre que format.nombre écrit avec decimales chiffres après la
|
||||
// virgule ; sinon le texte est t(cle) — « — » pour une population vide,
|
||||
|
|
@ -51,9 +57,30 @@ const LECTURES = new Map([
|
|||
['effectifManque', (l) => l.troisChiffres.effectifManque.tous],
|
||||
['ecartItineraireMax', (l) => l.troisChiffres.ecartItineraireMax?.tous ?? null],
|
||||
['ecartAuPlafondAPriori', (l) => l.ecartAuPlafondAPrioriMax],
|
||||
['jamaisServies', (l) => l.mesures.objectifs?.jamaisServies ?? null],
|
||||
['horsObjectifMax', (l) => l.mesures.objectifs?.horsObjectifMax ?? null],
|
||||
['toursHorsObjectif', (l) => l.mesures.objectifs?.horsObjectif ?? null],
|
||||
['toursSelonObjectif', (l) => l.mesures.objectifs?.agregat.tous.min ?? null],
|
||||
['animateursRencontres', (l) => l.mesures.animateurs?.agregat.tous.min ?? null],
|
||||
['animateursNonRencontres', (l) => l.mesures.animateurs?.nonRencontres ?? null],
|
||||
['certificat', (l) => l.minimumAtteint],
|
||||
]);
|
||||
|
||||
// Les colonnes que le tableau ne porte que sous une condition : [critère,
|
||||
// valeur], la colonne présente quand le critère compte et, valeur non
|
||||
// null, quand il classe cette valeur — les personnes jamais servies sous
|
||||
// « chacun au moins une fois », le plus de tours hors objectif d'une
|
||||
// personne sous le troisième mode. Toute autre colonne est toujours là. La
|
||||
// Map ne sert qu'à retrouver la condition d'une colonne.
|
||||
const SOUS_CONDITION = new Map([
|
||||
['jamaisServies', ['toursHorsObjectif', 'jamaisServies']],
|
||||
['horsObjectifMax', ['toursHorsObjectif', 'horsObjectifMax']],
|
||||
['toursHorsObjectif', ['toursHorsObjectif', 'toursHorsObjectif']],
|
||||
['toursSelonObjectif', ['toursHorsObjectif', null]],
|
||||
['animateursRencontres', ['animateursNonRencontres', null]],
|
||||
['animateursNonRencontres', ['animateursNonRencontres', 'animateursNonRencontres']],
|
||||
]);
|
||||
|
||||
// Les colonnes dont la valeur n'existe pas quand le plafond a priori est
|
||||
// inconnu : elles s'écrivent « inconnu », jamais « — » (§ 12.6).
|
||||
const SELON_A_PRIORI = new Set(['ecartItineraireMax', 'ecartAuPlafondAPriori']);
|
||||
|
|
@ -68,10 +95,12 @@ const TEXTUELLES = new Set(['proposition', 'certificat', 'imposes', 'commandes']
|
|||
|
||||
/** Les colonnes du tableau, dans l'ordre d'affichage : le rang et
|
||||
* l'identifiant, puis les mesures — les trois minimums des rencontres en
|
||||
* tête —, le certificat, les imposés et les commandes. Chacune porte son nom,
|
||||
* tête, celles des objectifs et des animateurs en dernier —, le certificat,
|
||||
* les imposés et les commandes ; toutes celles qu'un tableau peut porter,
|
||||
* tableau() rendant celles que porte le sien. Chacune porte son nom,
|
||||
* la clé de son en-tête, qui nomme son unité, celle de son explication,
|
||||
* le texte de l'infobulle de son en-tête et du critère qui la désigne, et
|
||||
* nombre, vrai quand ses cellules portent un nombre. */
|
||||
* le texte de l'infobulle de son en-tête et de la valeur appliquée qui la
|
||||
* désigne, et nombre, vrai quand ses cellules portent un nombre. */
|
||||
export const COLONNES = Object.freeze(
|
||||
['rang', 'proposition', ...LECTURES.keys(), 'imposes', 'commandes'].map((nom) =>
|
||||
Object.freeze({ nom, cle: `propositions.colonne.${nom}`, explication: `propositions.explication.${nom}`, nombre: !TEXTUELLES.has(nom) }),
|
||||
|
|
@ -86,44 +115,64 @@ const GROUPES = [
|
|||
['repetitions', ['rencontresRepetees', 'maxRencontresPaire', 'retoursChoisis']],
|
||||
['diversite', ['appartenancesVues', 'totalRedondance', 'diversite']],
|
||||
['manque', ['manqueMax', 'effectifManque', 'ecartItineraireMax', 'ecartAuPlafondAPriori']],
|
||||
['objectifs', ['jamaisServies', 'horsObjectifMax', 'toursHorsObjectif', 'toursSelonObjectif', 'animateursRencontres', 'animateursNonRencontres']],
|
||||
];
|
||||
|
||||
// Les segments de la rangée des groupes, dans l'ordre de COLONNES : un
|
||||
// groupe couvre ses colonnes d'un seul segment, une colonne hors groupe
|
||||
// forme le sien, sans clé. Un groupe dont les colonnes ne se suivent pas
|
||||
// dans COLONNES lève au chargement du module.
|
||||
function segmenter() {
|
||||
const noms = COLONNES.map(({ nom }) => nom);
|
||||
// Les segments de la rangée des groupes sur les colonnes noms, une suite de
|
||||
// COLONNES : un groupe couvre celles de ses colonnes que noms porte d'un seul
|
||||
// segment, une colonne hors groupe forme le sien, sans clé ; un groupe dont
|
||||
// noms ne porte aucune colonne n'a pas de segment. Un groupe dont les
|
||||
// colonnes ne se suivent pas dans noms lève : au chargement du module pour
|
||||
// COLONNES entier.
|
||||
function segmenter(noms) {
|
||||
const segments = [];
|
||||
for (let rang = 0; rang < noms.length; ) {
|
||||
const groupe = GROUPES.find(([, colonnes]) => colonnes[0] === noms[rang]);
|
||||
const groupe = GROUPES.find(([, colonnes]) => colonnes.includes(noms[rang]));
|
||||
if (groupe === undefined) {
|
||||
if (GROUPES.some(([, colonnes]) => colonnes.includes(noms[rang]))) throw new Error(`colonne groupée hors de son groupe : ${noms[rang]}`);
|
||||
segments.push({ cle: null, colonnes: [noms[rang]] });
|
||||
rang += 1;
|
||||
continue;
|
||||
}
|
||||
const [nom, colonnes] = groupe;
|
||||
colonnes.forEach((colonne, decalage) => {
|
||||
const portees = colonnes.filter((colonne) => noms.includes(colonne));
|
||||
portees.forEach((colonne, decalage) => {
|
||||
if (noms[rang + decalage] !== colonne) throw new Error(`groupe ${nom} : colonnes non voisines`);
|
||||
});
|
||||
segments.push({ cle: `propositions.groupe.${nom}`, colonnes });
|
||||
rang += colonnes.length;
|
||||
segments.push({ cle: `propositions.groupe.${nom}`, colonnes: portees });
|
||||
rang += portees.length;
|
||||
}
|
||||
return segments.map(({ cle, colonnes }) => Object.freeze({ cle, etendue: colonnes.length, colonnes: Object.freeze([...colonnes]) }));
|
||||
return Object.freeze(
|
||||
segments.map(({ cle, colonnes }) => Object.freeze({ cle, etendue: colonnes.length, colonnes: Object.freeze([...colonnes]) })),
|
||||
);
|
||||
}
|
||||
|
||||
/** La rangée des groupes d'en-têtes, au-dessus de celle des colonnes :
|
||||
* { cle, etendue, colonnes } par segment, dans l'ordre de COLONNES — cle,
|
||||
* la clé du libellé du groupe, null pour une colonne hors groupe ;
|
||||
* etendue, le nombre de colonnes couvertes ; colonnes, leurs noms. Les
|
||||
* segments couvrent chaque colonne une fois. */
|
||||
export const GROUPES_COLONNES = Object.freeze(segmenter());
|
||||
/** La rangée des groupes d'en-têtes de COLONNES entier, au-dessus de celle
|
||||
* des colonnes : { cle, etendue, colonnes } par segment, dans l'ordre de
|
||||
* COLONNES — cle, la clé du libellé du groupe, null pour une colonne hors
|
||||
* groupe ; etendue, le nombre de colonnes couvertes ; colonnes, leurs noms.
|
||||
* Les segments couvrent chaque colonne une fois. tableau() rend ceux des
|
||||
* colonnes que porte le sien. */
|
||||
export const GROUPES_COLONNES = segmenter(COLONNES.map(({ nom }) => nom));
|
||||
|
||||
// Les colonnes que porte le tableau d'une table d'évaluation, dans l'ordre de
|
||||
// COLONNES : chacune hors de SOUS_CONDITION, et celles dont la condition
|
||||
// tient sur l'état des critères de la table, {nom, coche, sansObjet,
|
||||
// valeurs}. Un critère compte quand il est coché et a un objet.
|
||||
function colonnesPortees(criteres) {
|
||||
const comptent = new Map(criteres.filter(({ coche, sansObjet }) => coche && !sansObjet).map(({ nom, valeurs }) => [nom, valeurs]));
|
||||
return COLONNES.filter(({ nom }) => {
|
||||
const condition = SOUS_CONDITION.get(nom);
|
||||
if (condition === undefined) return true;
|
||||
const [critere, valeur] = condition;
|
||||
const valeurs = comptent.get(critere);
|
||||
return valeurs !== undefined && (valeur === null || valeurs.includes(valeur));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* La clé de l'explication d'une colonne : le texte de l'infobulle de son
|
||||
* en-tête, et celui du nom de critère qui la désigne dans la phrase de
|
||||
* l'ordre (colonneDuCritere) — un critère et sa colonne partagent la même.
|
||||
* en-tête, et celui du nom qui la désigne dans la phrase de l'ordre
|
||||
* (colonneDeValeur) — une valeur appliquée et sa colonne partagent la même.
|
||||
* Lève TypeError pour un nom qui n'est pas une colonne de COLONNES.
|
||||
*
|
||||
* @param {string} nom le nom d'une colonne
|
||||
|
|
@ -154,12 +203,12 @@ function certificat(ligne) {
|
|||
return { colonne: 'certificat', valeur, cle, decimales: 0 };
|
||||
}
|
||||
|
||||
// Les cellules de mesure d'une ligne comparable, dans l'ordre de LECTURES.
|
||||
// L'a priori est inconnu quand troisChiffres ne porte pas d'écart
|
||||
// d'itinéraire du tout.
|
||||
function cellules(ligne) {
|
||||
// Les cellules de mesure d'une ligne comparable, une par colonne de mesure
|
||||
// portée, dans l'ordre de LECTURES. L'a priori est inconnu quand
|
||||
// troisChiffres ne porte pas d'écart d'itinéraire du tout.
|
||||
function cellules(ligne, portees) {
|
||||
const inconnu = ligne.troisChiffres.ecartItineraireMax === null;
|
||||
return [...LECTURES].map(([colonne, lire]) => {
|
||||
return [...LECTURES].filter(([colonne]) => portees.has(colonne)).map(([colonne, lire]) => {
|
||||
if (colonne === 'certificat') return certificat(ligne);
|
||||
if (inconnu && SELON_A_PRIORI.has(colonne)) return { colonne, valeur: null, cle: INCONNU, decimales: 0 };
|
||||
return nombre(colonne, lire(ligne));
|
||||
|
|
@ -181,14 +230,11 @@ const codeDe = (raison) => ({ cle: raison.code, details: raison });
|
|||
// null quand elle n'en nomme aucune.
|
||||
const raisonDe = (raison) => ({ ...codeDe(raison), participant: raison.participant ?? null });
|
||||
|
||||
// Les colonnes que couvre la cellule des raisons d'une ligne hors
|
||||
// classement : celles des mesures et celle des imposés.
|
||||
const ETENDUE_RAISONS = LECTURES.size + 1;
|
||||
|
||||
// La ligne d'un placement mesuré, ou celle d'un placement hors classement.
|
||||
// rang : la place dans le classement, à partir de 1 ; null hors classement
|
||||
// et pour le retenu.
|
||||
function ligneDe(ligne, table, idRetenu, rang = null) {
|
||||
// portees : les noms des colonnes que porte le tableau ; rang : la place
|
||||
// dans le classement, à partir de 1 ; null hors classement et pour le
|
||||
// retenu.
|
||||
function ligneDe(ligne, table, idRetenu, portees, rang = null) {
|
||||
const retenue = ligne.id === idRetenu;
|
||||
if (ligne.statut !== 'comparable') {
|
||||
return {
|
||||
|
|
@ -205,83 +251,106 @@ function ligneDe(ligne, table, idRetenu, rang = null) {
|
|||
rang,
|
||||
retenue,
|
||||
comparable: true,
|
||||
cellules: cellules(ligne),
|
||||
cellules: cellules(ligne, portees),
|
||||
imposes: imposes(ligne, table.imposes),
|
||||
violations: ligne.violations.map(codeDe),
|
||||
};
|
||||
}
|
||||
|
||||
// La colonne que désigne chaque critère du classement (§ 5.7) : celle qui
|
||||
// porte la grandeur qu'il compare ; null pour les tours hors objectif et les
|
||||
// animateurs non rencontrés, que le tableau ne porte pas en colonne. La Map
|
||||
// ne sert qu'à retrouver la colonne d'un critère ; la phrase suit l'ordre que
|
||||
// le classement rend.
|
||||
const COLONNE_DU_CRITERE = new Map([
|
||||
// La colonne que désigne chaque valeur qui classe (§ 5.7, A11) : celle qui
|
||||
// la porte ; l'identifiant croissant, le dernier départage, désigne la
|
||||
// colonne des propositions. La Map ne sert qu'à retrouver la colonne d'une
|
||||
// valeur ; la phrase suit l'ordre que le classement rend.
|
||||
const COLONNE_DE_VALEUR = new Map([
|
||||
['ecartAuPlafondAPrioriMax', 'ecartAuPlafondAPriori'],
|
||||
['excedentCollisions', 'excedentCollisions'],
|
||||
['collisionsCumulees', 'collisionsCumulees'],
|
||||
['toursHorsObjectif', null],
|
||||
['rencontresRepetees', 'rencontresRepetees'],
|
||||
['animateursNonRencontres', null],
|
||||
['jamaisServies', 'jamaisServies'],
|
||||
['horsObjectifMax', 'horsObjectifMax'],
|
||||
['toursHorsObjectif', 'toursHorsObjectif'],
|
||||
['rencontresRepeteesChoisies', 'rencontresRepetees'],
|
||||
['retoursChoisis', 'retoursChoisis'],
|
||||
['animateursNonRencontres', 'animateursNonRencontres'],
|
||||
['redondance', 'totalRedondance'],
|
||||
['identifiant', 'proposition'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* La colonne du tableau que désigne un critère du classement, ou
|
||||
* 'identifiant', le dernier départage, qui désigne la colonne des
|
||||
* propositions ; null pour un critère du registre que le tableau ne porte
|
||||
* pas en colonne. Lève TypeError pour un nom que la table ne connaît pas :
|
||||
* un critère ajouté au classement doit dire laquelle il compare, ou
|
||||
* qu'il n'en a pas.
|
||||
* La colonne du tableau que désigne une valeur qui classe (valeursQuiClassent,
|
||||
* criteres.js), ou 'identifiant', le dernier départage, qui désigne la
|
||||
* colonne des propositions. Lève TypeError pour un nom que la table ne
|
||||
* connaît pas : une valeur ajoutée au classement doit dire laquelle elle
|
||||
* compare.
|
||||
*
|
||||
* @param {string} critere un nom de CRITERES (criteres.js), ou 'identifiant'
|
||||
* @returns {string|null} le nom d'une colonne de COLONNES, ou null
|
||||
* @param {string} valeur une valeur qui classe, ou 'identifiant'
|
||||
* @returns {string} le nom d'une colonne de COLONNES
|
||||
*/
|
||||
export function colonneDuCritere(critere) {
|
||||
const colonne = COLONNE_DU_CRITERE.get(critere);
|
||||
if (colonne === undefined) throw new TypeError(`critère sans colonne : ${critere}`);
|
||||
export function colonneDeValeur(valeur) {
|
||||
const colonne = COLONNE_DE_VALEUR.get(valeur);
|
||||
if (colonne === undefined) throw new TypeError(`valeur sans colonne : ${valeur}`);
|
||||
return colonne;
|
||||
}
|
||||
|
||||
// Les noms des critères appliqués, palier après palier : la liste aplatie
|
||||
// des criteresAppliques que rend classer.
|
||||
const aplatir = (criteresAppliques) => criteresAppliques.flatMap(({ criteres }) => criteres.map(({ nom }) => nom));
|
||||
|
||||
// Un morceau de phrase : un texte de liaison, ou un nom qui désigne une
|
||||
// colonne ; un nom de critère sans colonne s'affiche comme un texte.
|
||||
const liaison = (cle, details = {}) => ({ cle, details, critere: null, colonne: null });
|
||||
const designant = (critere, cle) => ({ cle, details: {}, critere, colonne: colonneDuCritere(critere) });
|
||||
// colonne — celui d'une valeur appliquée, sous la clé
|
||||
// moteur.valeur.<valeur>, avec le critère qui la classe.
|
||||
const liaison = (cle, details = {}) => ({ cle, details, critere: null, valeur: null, colonne: null });
|
||||
const designant = (critere, valeur, cle = `moteur.valeur.${valeur}`) => ({ cle, details: {}, critere, valeur, colonne: colonneDeValeur(valeur) });
|
||||
|
||||
// La phrase des critères appliqués, morceau par morceau : le début, chaque
|
||||
// critère de la liste aplatie par son nom moteur.critere.<nom>, puis
|
||||
// l'identifiant croissant, séparés, et la fin. Bout à bout, les morceaux
|
||||
// traduits composent le texte de propositions.ordre.criteres sur la liste
|
||||
// aplatie : sous l'ordre par défaut et les quatre cases cochées, celui de
|
||||
// l'ordre fixe de référence.
|
||||
function phraseDesCriteres(criteresAppliques) {
|
||||
const noms = [
|
||||
...aplatir(criteresAppliques).map((critere) => designant(critere, `moteur.critere.${critere}`)),
|
||||
designant('identifiant', 'propositions.ordre.identifiant'),
|
||||
// Des listes de morceaux mises bout à bout, le morceau separateur entre deux.
|
||||
const joindre = (listes, separateur) => listes.flatMap((morceaux, rang) => (rang === 0 ? morceaux : [separateur(), ...morceaux]));
|
||||
|
||||
// Les noms d'un critère appliqué, {nom, valeurs, poids} : chaque valeur
|
||||
// qu'il classe, dans l'ordre, séparées par « puis » comme deux paliers.
|
||||
const nomsDuCritere = ({ nom, valeurs }) =>
|
||||
joindre(
|
||||
valeurs.map((valeur) => [designant(nom, valeur)]),
|
||||
() => liaison('propositions.ordre.separateur'),
|
||||
);
|
||||
|
||||
// Un palier appliqué en morceaux : les noms de son critère, seul ; pour un
|
||||
// palier partagé, « ensemble, » puis chaque critère suivi de son poids,
|
||||
// séparés par une virgule.
|
||||
function palierEnMorceaux({ criteres }) {
|
||||
if (criteres.length === 1) return nomsDuCritere(criteres[0]);
|
||||
return [
|
||||
liaison('propositions.ordre.ensemble'),
|
||||
...joindre(
|
||||
criteres.map((critere) => [...nomsDuCritere(critere), liaison('propositions.ordre.poids', { poids: critere.poids })]),
|
||||
() => liaison('propositions.ordre.virgule'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// La phrase des critères appliqués, morceau par morceau (A11) : le début,
|
||||
// chaque palier appliqué dans l'ordre, puis l'identifiant croissant, séparés
|
||||
// par « puis », et la fin. Chaque nom est celui de la valeur que le palier
|
||||
// applique — « retours » pour les rencontres répétées sous « nouvelle
|
||||
// table » seule. Sous l'ordre par défaut et les quatre cases cochées, sans
|
||||
// objectif ni case des animateurs, chaque palier porte un critère qui
|
||||
// classe une valeur du nom de son critère, et les morceaux traduits bout à
|
||||
// bout composent le texte de propositions.ordre.criteres, celui de l'ordre
|
||||
// fixe de référence.
|
||||
function phraseDesCriteres(criteresAppliques) {
|
||||
const paliers = [...criteresAppliques.map(palierEnMorceaux), [designant('identifiant', 'identifiant', 'propositions.ordre.identifiant')]];
|
||||
return [
|
||||
liaison('propositions.ordre.debut'),
|
||||
...noms.flatMap((nom, rang) => (rang === 0 ? [nom] : [liaison('propositions.ordre.separateur'), nom])),
|
||||
...joindre(paliers, () => liaison('propositions.ordre.separateur')),
|
||||
liaison('propositions.ordre.fin'),
|
||||
];
|
||||
}
|
||||
|
||||
// Le palier qui prend la place du palier sauté : ses critères {nom, poids},
|
||||
// ceux du palier appliqué au rang rangSaute ; null quand aucun palier ne
|
||||
// suit le palier sauté.
|
||||
// Le palier qui prend la place du palier sauté : ses critères {nom, poids,
|
||||
// valeurs}, ceux du palier appliqué au rang rangSaute ; null quand aucun
|
||||
// palier ne suit le palier sauté.
|
||||
function palierAppliqueASaPlace({ criteresAppliques, rangSaute }) {
|
||||
const palier = criteresAppliques[rangSaute];
|
||||
return palier === undefined ? null : palier.criteres.map(({ nom, poids }) => ({ nom, poids }));
|
||||
return palier === undefined ? null : palier.criteres.map(({ nom, poids, valeurs }) => ({ nom, poids, valeurs: [...valeurs] }));
|
||||
}
|
||||
|
||||
// Les phrases de l'ordre (§ 5.7), chacune une liste de morceaux : quand le
|
||||
// plafond a priori manque, le critère sauté et le palier appliqué à sa
|
||||
// place, s'il en est un ; puis les critères appliqués.
|
||||
// place, s'il en est un ; puis les paliers appliqués.
|
||||
function ordre(classement) {
|
||||
const { criteresAppliques, critereSaute } = classement;
|
||||
const phrases = [];
|
||||
|
|
@ -300,13 +369,18 @@ function ordre(classement) {
|
|||
* placement de sa proposition d'origine (retenuAjuste) ; il choisit le
|
||||
* libellé de la ligne du retenu
|
||||
* @returns {{ordre: Array<Array<{cle: string, details: Object, critere: string|null,
|
||||
* colonne: string|null}>>, colonnes: typeof COLONNES,
|
||||
* etendueRaisons: number, lignes: Object[], horsClassement: Object[], retenu: Object|null}}
|
||||
* valeur: string|null, colonne: string|null}>>, colonnes: typeof COLONNES,
|
||||
* groupes: typeof GROUPES_COLONNES, etendueRaisons: number, lignes: Object[],
|
||||
* horsClassement: Object[], retenu: Object|null}}
|
||||
* ordre : les phrases de l'ordre, chacune une liste de morceaux que t
|
||||
* traduit et que l'affichage met bout à bout ; un morceau qui nomme un
|
||||
* critère, ou l'identifiant croissant, porte critere et la colonne qu'il
|
||||
* désigne, null pour un texte de liaison ; lignes : les comparables dans
|
||||
* l'ordre du classement, chacune { id, rang, retenue, comparable: true,
|
||||
* traduit et que l'affichage met bout à bout ; un morceau qui nomme une
|
||||
* valeur appliquée, ou l'identifiant croissant, porte le critère qui la
|
||||
* classe, la valeur et la colonne qu'elle désigne — 'identifiant' pour
|
||||
* le dernier départage —, null pour un texte de liaison ; colonnes : celles
|
||||
* de COLONNES que le tableau porte, les colonnes des objectifs et des
|
||||
* animateurs selon l'état des critères de la table ; groupes : les
|
||||
* segments de la rangée des groupes sur ces colonnes ; lignes : les
|
||||
* comparables dans l'ordre du classement, chacune { id, rang, retenue, comparable: true,
|
||||
* cellules, imposes, violations } ;
|
||||
* horsClassement : les autres par identifiant, { id, rang: null, retenue,
|
||||
* comparable: false, statut, raisons } — statut la clé de « non comparable »,
|
||||
|
|
@ -319,24 +393,29 @@ function ordre(classement) {
|
|||
*/
|
||||
export function tableau(tableEvaluation, { ajuste = false } = {}) {
|
||||
const idRetenu = tableEvaluation.retenu?.id ?? null;
|
||||
const colonnes = colonnesPortees(tableEvaluation.criteres);
|
||||
const portees = new Set(colonnes.map(({ nom }) => nom));
|
||||
// Les comparables ouvrent la liste, dans l'ordre du classement : leur
|
||||
// place y est leur rang.
|
||||
const toutes = tableEvaluation.propositions.map((ligne, place) => ligneDe(ligne, tableEvaluation, idRetenu, place + 1));
|
||||
const toutes = tableEvaluation.propositions.map((ligne, place) => ligneDe(ligne, tableEvaluation, idRetenu, portees, place + 1));
|
||||
return {
|
||||
ordre: ordre(tableEvaluation.classement),
|
||||
colonnes: COLONNES,
|
||||
etendueRaisons: ETENDUE_RAISONS,
|
||||
colonnes,
|
||||
groupes: segmenter(colonnes.map(({ nom }) => nom)),
|
||||
// La cellule des raisons d'une ligne hors classement couvre les colonnes
|
||||
// de mesure portées et celle des imposés.
|
||||
etendueRaisons: [...LECTURES.keys()].filter((nom) => portees.has(nom)).length + 1,
|
||||
lignes: toutes.filter(({ comparable }) => comparable),
|
||||
horsClassement: toutes.filter(({ comparable }) => !comparable),
|
||||
retenu: tableEvaluation.retenu === null ? null : ligneDuRetenu(tableEvaluation, idRetenu, ajuste),
|
||||
retenu: tableEvaluation.retenu === null ? null : ligneDuRetenu(tableEvaluation, idRetenu, portees, ajuste),
|
||||
};
|
||||
}
|
||||
|
||||
// La ligne du retenu, et son libellé : « tel que » la proposition d'origine,
|
||||
// ou « ajusté » depuis elle.
|
||||
function ligneDuRetenu(tableEvaluation, idRetenu, ajuste) {
|
||||
function ligneDuRetenu(tableEvaluation, idRetenu, portees, ajuste) {
|
||||
const cle = ajuste ? 'propositions.retenu.ajuste' : 'propositions.retenu.origine';
|
||||
return { ...ligneDe(tableEvaluation.retenu, tableEvaluation, idRetenu), libelle: { cle, details: { proposition: idRetenu } } };
|
||||
return { ...ligneDe(tableEvaluation.retenu, tableEvaluation, idRetenu, portees), libelle: { cle, details: { proposition: idRetenu } } };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -380,9 +459,10 @@ function egaux(a, b) {
|
|||
// remplacerait.
|
||||
const CHAMPS_DU_PLACEMENT = ['siegesAttribues', 'tables', 'capacites', 'tours', 'participants', 'placement'];
|
||||
|
||||
/** Vrai quand un retenu existe et ne porte plus le placement de sa proposition d'origine :
|
||||
* tables, capacités, tours, participants ou placement diffèrent, en profondeur, de ceux
|
||||
* de la proposition retenu.proposition, ou celle-ci n'est plus dans la liste. */
|
||||
/** Vrai quand un retenu existe et ne porte plus le placement de sa
|
||||
* proposition d'origine : tables, capacités, tours, participants ou
|
||||
* placement diffèrent, en profondeur, de ceux de la proposition
|
||||
* retenu.proposition, ou celle-ci n'est plus dans la liste. */
|
||||
export function retenuAjuste(charge) {
|
||||
const { retenu } = charge;
|
||||
if (retenu === null) return false;
|
||||
|
|
@ -430,9 +510,9 @@ export function demandeConfirmation({ refus, charge }, id) {
|
|||
// dans le modèle pur et tenu par l'immuabilité de son entrée.
|
||||
const evaluees = new WeakMap();
|
||||
|
||||
// Vrai quand chaque objet atteint depuis valeur est figé. Un objet figé ne
|
||||
// redevient jamais modifiable : vrai une fois, vrai pour toujours.
|
||||
function figeePartout(valeur) {
|
||||
/** Vrai quand chaque objet atteint depuis valeur est figé. Un objet figé ne
|
||||
* redevient jamais modifiable : vrai une fois, vrai pour toujours. */
|
||||
export function figeePartout(valeur) {
|
||||
if (valeur === null || typeof valeur !== 'object') return true;
|
||||
if (!Object.isFrozen(valeur)) return false;
|
||||
for (const cle of Object.keys(valeur)) if (!figeePartout(valeur[cle])) return false;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { PLAN_PARFAIT_PETITE } from '../../demo/catalogue.js';
|
|||
import { texteLivre } from '../../demo/livrees.js';
|
||||
import { CRITERES, classer } from '../../moteur/classement.js';
|
||||
import { indexerPlan, normaliser } from '../../moteur/configuration.js';
|
||||
import { CRITERES as REGISTRE, MODES_OBJECTIFS, valeursQuiClassent } from '../../moteur/criteres.js';
|
||||
import { mesurer } from '../../moteur/indicateurs.js';
|
||||
import { ecartsAuPlafondAPriori, minimumAtteint, troisChiffres } from '../../moteur/manque.js';
|
||||
import { plafondsAPriori, plafondsRealises } from '../../moteur/plafond.js';
|
||||
|
|
@ -34,7 +35,7 @@ import {
|
|||
GROUPES_COLONNES,
|
||||
TAILLE_PAGE,
|
||||
changerDePage,
|
||||
colonneDuCritere,
|
||||
colonneDeValeur,
|
||||
demandeConfirmation,
|
||||
evaluation,
|
||||
explicationDeColonne,
|
||||
|
|
@ -123,6 +124,49 @@ const DEUX_COLLEGUES = [
|
|||
[[1, 2, 6], [3, 4, 5]],
|
||||
];
|
||||
|
||||
// Les colonnes du tableau sans objectif ni case des animateurs, dans l'ordre
|
||||
// d'affichage : celles que le tableau portait avant le sixième groupe.
|
||||
const COLONNES_PAR_DEFAUT = [
|
||||
'rang',
|
||||
'proposition',
|
||||
'rencontresTous',
|
||||
'rencontresMobiles',
|
||||
'rencontresAncres',
|
||||
'collisionsCumulees',
|
||||
'pairesDistinctes',
|
||||
'excedentCollisions',
|
||||
'rencontresRepetees',
|
||||
'maxRencontresPaire',
|
||||
'retoursChoisis',
|
||||
'appartenancesVues',
|
||||
'totalRedondance',
|
||||
'diversite',
|
||||
'manqueMax',
|
||||
'effectifManque',
|
||||
'ecartItineraireMax',
|
||||
'ecartAuPlafondAPriori',
|
||||
'certificat',
|
||||
'imposes',
|
||||
'commandes',
|
||||
];
|
||||
|
||||
// Les colonnes du sixième groupe, les objectifs des tables et les
|
||||
// animateurs, dans l'ordre d'affichage ; les deux premières ne paraissent
|
||||
// jamais ensemble, chacune sous son mode.
|
||||
const SIXIEME = ['jamaisServies', 'horsObjectifMax', 'toursHorsObjectif', 'toursSelonObjectif', 'animateursRencontres', 'animateursNonRencontres'];
|
||||
|
||||
// La petite démonstration au conflit inévitable, les personnes 1 à 6
|
||||
// « finance », la première table attendant « finance » pour la soirée ; les
|
||||
// réglages donnés s'ajoutent à ceux de la charge.
|
||||
function petiteAObjectifs(reglages = {}) {
|
||||
const charge = structuredClone(chargeDemo('petite-conflit'));
|
||||
for (const participant of charge.participants) if (participant.id <= 6) participant.etiquettes = ['finance'];
|
||||
const [premiere] = [...charge.tables].sort((a, b) => a.id - b.id);
|
||||
premiere.objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }];
|
||||
Object.assign(charge.reglages, reglages);
|
||||
return charge;
|
||||
}
|
||||
|
||||
// Trois plans de la petite démonstration au conflit inévitable, nommés par
|
||||
// leurs collisions (cumulées, distinctes), au même plus grand écart au
|
||||
// plafond a priori : ceux de l'épreuve de la table d'évaluation.
|
||||
|
|
@ -227,7 +271,8 @@ describe('tableau : chaque cellule porte la valeur de la table d’évaluation (
|
|||
test('chaque cellule égale la valeur de sa ligne ; un nombre se met en forme, null porte la clé de « — »', () => {
|
||||
const table = evaluer(generee(chargeDemo('petite-conflit'), { graine: 5, nombre: 3, arret: 400 }));
|
||||
const vue = tableau(table);
|
||||
assert.deepEqual(vue.colonnes, COLONNES);
|
||||
assert.deepEqual(vue.colonnes.map(({ nom }) => nom), COLONNES_PAR_DEFAUT);
|
||||
for (const colonne of vue.colonnes) assert.ok(COLONNES.includes(colonne), colonne.nom);
|
||||
assert.equal(vue.lignes.length, 3);
|
||||
assert.deepEqual(vue.lignes.map(({ id }) => id), table.propositions.map(({ id }) => id));
|
||||
let vues = 0;
|
||||
|
|
@ -419,18 +464,40 @@ describe('tableau : l’ordre écrit (§ 5.7, § 17 point 4)', () => {
|
|||
['identifiant', 'proposition'],
|
||||
]);
|
||||
for (const morceau of vue.ordre[0]) {
|
||||
if (morceau.colonne === null) assert.equal(morceau.critere, null, morceau.cle);
|
||||
else assert.equal(t(morceau.cle, morceau.details), morceau.critere === 'identifiant' ? "l'identifiant croissant" : t(`moteur.critere.${morceau.critere}`));
|
||||
if (morceau.colonne === null) {
|
||||
assert.equal(morceau.critere, null, morceau.cle);
|
||||
assert.equal(morceau.valeur, null, morceau.cle);
|
||||
continue;
|
||||
}
|
||||
// Sous le défaut, la valeur appliquée porte le nom du critère.
|
||||
const attendu = morceau.critere === 'identifiant' ? "l'identifiant croissant" : t(`moteur.critere.${morceau.critere}`);
|
||||
assert.equal(t(morceau.cle, morceau.details), attendu);
|
||||
if (morceau.critere !== 'identifiant') assert.equal(morceau.cle, `moteur.valeur.${morceau.valeur}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('colonneDuCritere : chaque critère de CRITERES désigne une colonne de COLONNES ; un critère sans colonne lève', () => {
|
||||
test('colonneDeValeur : chaque valeur qui classe, sous tout mode et toute combinaison des cases, désigne une colonne de COLONNES ; une valeur inconnue lève', () => {
|
||||
const noms = new Set(COLONNES.map(({ nom }) => nom));
|
||||
assert.equal(CRITERES.length, 5);
|
||||
for (const critere of CRITERES) assert.ok(noms.has(colonneDuCritere(critere)), critere);
|
||||
assert.ok(noms.has(colonneDuCritere('identifiant')));
|
||||
assert.equal(colonneDuCritere('identifiant'), 'proposition');
|
||||
assert.throws(() => colonneDuCritere('moyenneRencontres'), TypeError);
|
||||
const valeurs = new Set();
|
||||
for (const mode of MODES_OBJECTIFS) {
|
||||
for (let masque = 0; masque < 16; masque += 1) {
|
||||
const contraintes = {
|
||||
separerAppartenances: (masque & 1) !== 0,
|
||||
nouveauxVoisins: (masque & 2) !== 0,
|
||||
nouvelleTable: (masque & 4) !== 0,
|
||||
varierAppartenances: (masque & 8) !== 0,
|
||||
};
|
||||
for (const nom of REGISTRE) for (const valeur of valeursQuiClassent(nom, contraintes, mode)) valeurs.add(valeur);
|
||||
}
|
||||
}
|
||||
assert.equal(valeurs.size, 10);
|
||||
for (const valeur of valeurs) assert.ok(noms.has(colonneDeValeur(valeur)), valeur);
|
||||
assert.equal(colonneDeValeur('rencontresRepeteesChoisies'), 'rencontresRepetees');
|
||||
assert.equal(colonneDeValeur('retoursChoisis'), 'retoursChoisis');
|
||||
assert.equal(colonneDeValeur('redondance'), 'totalRedondance');
|
||||
assert.equal(colonneDeValeur('identifiant'), 'proposition');
|
||||
assert.throws(() => colonneDeValeur('moyenneRencontres'), TypeError);
|
||||
assert.throws(() => colonneDeValeur('rencontresRepetees'), TypeError);
|
||||
});
|
||||
|
||||
test('a priori inconnu : la phrase nomme le critère sauté et « excédent de collisions » appliqué à sa place ; les critères appliqués désignent toujours leur colonne', () => {
|
||||
|
|
@ -440,8 +507,9 @@ describe('tableau : l’ordre écrit (§ 5.7, § 17 point 4)', () => {
|
|||
assert.deepEqual(vue.ordre[0], [
|
||||
{
|
||||
cle: 'propositions.ordre.saute',
|
||||
details: { saute: 'ecartAuPlafondAPrioriMax', applique: [{ nom: 'excedentCollisions', poids: 1 }] },
|
||||
details: { saute: 'ecartAuPlafondAPrioriMax', applique: [{ nom: 'excedentCollisions', poids: 1, valeurs: ['excedentCollisions'] }] },
|
||||
critere: null,
|
||||
valeur: null,
|
||||
colonne: null,
|
||||
},
|
||||
]);
|
||||
|
|
@ -502,7 +570,7 @@ describe('tableau : l’ordre écrit (§ 5.7, § 17 point 4)', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('les objectifs comptés se nomment à leur palier, en texte : colonneDuCritere rend null pour les critères que le tableau ne porte pas en colonne', () => {
|
||||
test('les objectifs comptés se nomment à leur palier et désignent la colonne des tours hors objectif', () => {
|
||||
const vue = tableau(evaluer(generee(aObjectifs(), { graine: 3, nombre: 2, arret: 200 })));
|
||||
assert.equal(
|
||||
phraseDe(vue.ordre[0]),
|
||||
|
|
@ -511,12 +579,16 @@ describe('tableau : l’ordre écrit (§ 5.7, § 17 point 4)', () => {
|
|||
"puis l'identifiant croissant.",
|
||||
);
|
||||
const horsObjectif = vue.ordre[0].find(({ critere }) => critere === 'toursHorsObjectif');
|
||||
assert.deepEqual(horsObjectif, { cle: 'moteur.critere.toursHorsObjectif', details: {}, critere: 'toursHorsObjectif', colonne: null });
|
||||
assert.equal(colonneDuCritere('toursHorsObjectif'), null);
|
||||
assert.equal(colonneDuCritere('animateursNonRencontres'), null);
|
||||
assert.deepEqual(horsObjectif, {
|
||||
cle: 'moteur.valeur.toursHorsObjectif',
|
||||
details: {},
|
||||
critere: 'toursHorsObjectif',
|
||||
valeur: 'toursHorsObjectif',
|
||||
colonne: 'toursHorsObjectif',
|
||||
});
|
||||
});
|
||||
|
||||
test('sous un ordre réglé, la phrase suit les paliers, aplatis ; a priori inconnu, le palier qui suit l’écart prend sa place, nommé en entier', () => {
|
||||
test('sous un ordre réglé, la phrase dit les paliers ; a priori inconnu, le palier qui suit l’écart prend sa place, nommé en entier', () => {
|
||||
const priorites = [
|
||||
[e('toursHorsObjectif')],
|
||||
[e('collisionsCumulees')],
|
||||
|
|
@ -529,13 +601,16 @@ describe('tableau : l’ordre écrit (§ 5.7, § 17 point 4)', () => {
|
|||
assert.equal(
|
||||
phraseDe(tableau(evaluer(charge)).ordre[0]),
|
||||
"Ordre du classement, chaque critère au plus faible d'abord : tours hors objectif, puis collisions cumulées, " +
|
||||
"puis écart au plafond a priori, puis excédent de collisions, puis redondance d'appartenance, " +
|
||||
"puis écart au plafond a priori, puis ensemble, excédent de collisions (poids 1), redondance d'appartenance (poids 2), " +
|
||||
"puis rencontres répétées, puis l'identifiant croissant.",
|
||||
);
|
||||
const inconnu = tableau(evaluer(charge, { plafondsAPriori: null }));
|
||||
assert.deepEqual(inconnu.ordre[0][0].details, {
|
||||
saute: 'ecartAuPlafondAPrioriMax',
|
||||
applique: [{ nom: 'excedentCollisions', poids: 1 }, { nom: 'redondance', poids: 2 }],
|
||||
applique: [
|
||||
{ nom: 'excedentCollisions', poids: 1, valeurs: ['excedentCollisions'] },
|
||||
{ nom: 'redondance', poids: 2, valeurs: ['redondance'] },
|
||||
],
|
||||
});
|
||||
assert.equal(
|
||||
phraseDe(inconnu.ordre[0]),
|
||||
|
|
@ -546,6 +621,251 @@ describe('tableau : l’ordre écrit (§ 5.7, § 17 point 4)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('tableau : la phrase dit les paliers et les valeurs appliquées (§ 5.7, A11)', () => {
|
||||
const phraseDe = (morceaux) => morceaux.map(({ cle, details }) => t(cle, details)).join('');
|
||||
const designes = (morceaux) => morceaux.filter(({ colonne }) => colonne !== null).map(({ critere, valeur, colonne }) => [critere, valeur, colonne]);
|
||||
const e = (critere, poids = 1) => ({ critere, poids });
|
||||
const DEBUT = "Ordre du classement, chaque critère au plus faible d'abord : ";
|
||||
|
||||
test('« nouvelle table » cochée seule : le palier des rencontres répétées se nomme « retours » et désigne la colonne des retours choisis', () => {
|
||||
const charge = structuredClone(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT));
|
||||
for (const cle of ['separerAppartenances', 'nouveauxVoisins', 'varierAppartenances']) charge.reglages[cle] = false;
|
||||
const vue = tableau(evaluer(charge));
|
||||
assert.equal(phraseDe(vue.ordre[0]), `${DEBUT}écart au plafond a priori, puis retours, puis l'identifiant croissant.`);
|
||||
assert.deepEqual(designes(vue.ordre[0]), [
|
||||
['ecartAuPlafondAPrioriMax', 'ecartAuPlafondAPrioriMax', 'ecartAuPlafondAPriori'],
|
||||
['rencontresRepetees', 'retoursChoisis', 'retoursChoisis'],
|
||||
['identifiant', 'identifiant', 'proposition'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('a priori inconnu, le critère sauté : le palier appliqué à sa place se nomme par ses valeurs — « retours » sous « nouvelle table » seule, les deux valeurs sous « chacun au moins une fois »', () => {
|
||||
const PHRASE = 'Critère sauté, faute de plafond a priori par personne : écart au plafond a priori. Appliqué à sa place : ';
|
||||
const seule = structuredClone(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT));
|
||||
for (const cle of ['separerAppartenances', 'nouveauxVoisins', 'varierAppartenances']) seule.reglages[cle] = false;
|
||||
const [saute] = tableau(evaluer(seule, { plafondsAPriori: null })).ordre[0];
|
||||
assert.equal(t(saute.cle, saute.details), `${PHRASE}retours.`);
|
||||
const priorites = [
|
||||
[e('ecartAuPlafondAPrioriMax')],
|
||||
[e('toursHorsObjectif')],
|
||||
[e('excedentCollisions')],
|
||||
[e('collisionsCumulees')],
|
||||
[e('rencontresRepetees')],
|
||||
[e('redondance')],
|
||||
[e('animateursNonRencontres')],
|
||||
];
|
||||
const chacun = generee(petiteAObjectifs({ modeObjectifs: 'chacun', priorites }), { graine: 3, nombre: 2, arret: 200 });
|
||||
const [sauteChacun] = tableau(evaluer(chacun, { plafondsAPriori: null })).ordre[0];
|
||||
assert.equal(t(sauteChacun.cle, sauteChacun.details), `${PHRASE}personnes jamais servies, puis tours hors objectif.`);
|
||||
});
|
||||
|
||||
test('« chacun au moins une fois » : « personnes jamais servies, puis tours hors objectif », deux noms du même critère, chacun sa colonne', () => {
|
||||
const vue = tableau(evaluer(generee(petiteAObjectifs({ modeObjectifs: 'chacun' }), { graine: 3, nombre: 2, arret: 200 })));
|
||||
assert.equal(
|
||||
phraseDe(vue.ordre[0]),
|
||||
`${DEBUT}écart au plafond a priori, puis excédent de collisions, puis collisions cumulées, ` +
|
||||
"puis personnes jamais servies, puis tours hors objectif, puis rencontres répétées, puis redondance d'appartenance, " +
|
||||
"puis l'identifiant croissant.",
|
||||
);
|
||||
assert.deepEqual(
|
||||
designes(vue.ordre[0]).filter(([critere]) => critere === 'toursHorsObjectif'),
|
||||
[
|
||||
['toursHorsObjectif', 'jamaisServies', 'jamaisServies'],
|
||||
['toursHorsObjectif', 'toursHorsObjectif', 'toursHorsObjectif'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('« l’équité d’abord » : « plus de tours hors objectif d’une personne, puis tours hors objectif », chacun sa colonne', () => {
|
||||
const vue = tableau(evaluer(generee(petiteAObjectifs({ modeObjectifs: 'minimum' }), { graine: 3, nombre: 2, arret: 200 })));
|
||||
assert.ok(
|
||||
phraseDe(vue.ordre[0]).includes("puis collisions cumulées, puis plus de tours hors objectif d'une personne, puis tours hors objectif, puis rencontres répétées"),
|
||||
phraseDe(vue.ordre[0]),
|
||||
);
|
||||
assert.deepEqual(
|
||||
designes(vue.ordre[0]).filter(([critere]) => critere === 'toursHorsObjectif'),
|
||||
[
|
||||
['toursHorsObjectif', 'horsObjectifMax', 'horsObjectifMax'],
|
||||
['toursHorsObjectif', 'toursHorsObjectif', 'toursHorsObjectif'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('un palier partagé : « ensemble, », puis chaque nom suivi de son poids ; chaque nom désigne sa colonne, les poids restent du texte', () => {
|
||||
const charge = structuredClone(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT));
|
||||
charge.reglages.priorites = [
|
||||
[e('ecartAuPlafondAPrioriMax')],
|
||||
[e('excedentCollisions', 3), e('redondance', 2)],
|
||||
[e('collisionsCumulees')],
|
||||
[e('toursHorsObjectif')],
|
||||
[e('rencontresRepetees')],
|
||||
[e('animateursNonRencontres')],
|
||||
];
|
||||
const [morceaux] = tableau(evaluer(charge)).ordre;
|
||||
assert.equal(
|
||||
phraseDe(morceaux),
|
||||
`${DEBUT}écart au plafond a priori, puis ensemble, excédent de collisions (poids 3), redondance d'appartenance (poids 2), ` +
|
||||
"puis collisions cumulées, puis rencontres répétées, puis l'identifiant croissant.",
|
||||
);
|
||||
const debut = morceaux.findIndex(({ cle }) => cle === 'propositions.ordre.ensemble');
|
||||
assert.deepEqual(morceaux.slice(debut, debut + 5), [
|
||||
{ cle: 'propositions.ordre.ensemble', details: {}, critere: null, valeur: null, colonne: null },
|
||||
{ cle: 'moteur.valeur.excedentCollisions', details: {}, critere: 'excedentCollisions', valeur: 'excedentCollisions', colonne: 'excedentCollisions' },
|
||||
{ cle: 'propositions.ordre.poids', details: { poids: 3 }, critere: null, valeur: null, colonne: null },
|
||||
{ cle: 'propositions.ordre.virgule', details: {}, critere: null, valeur: null, colonne: null },
|
||||
{ cle: 'moteur.valeur.redondance', details: {}, critere: 'redondance', valeur: 'redondance', colonne: 'totalRedondance' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('les animateurs comptés : « animateurs non rencontrés » à leur palier, qui désigne sa colonne', () => {
|
||||
const charge = structuredClone(chargeDemo('grande'));
|
||||
charge.reglages.rencontrerAnimateurs = true;
|
||||
const vue = tableau(evaluer(generee(charge, { graine: 1, nombre: 1, arret: 2000 })));
|
||||
assert.ok(phraseDe(vue.ordre[0]).includes("puis rencontres répétées, puis animateurs non rencontrés, puis redondance d'appartenance"), phraseDe(vue.ordre[0]));
|
||||
assert.deepEqual(
|
||||
designes(vue.ordre[0]).filter(([critere]) => critere === 'animateursNonRencontres'),
|
||||
[['animateursNonRencontres', 'animateursNonRencontres', 'animateursNonRencontres']],
|
||||
);
|
||||
});
|
||||
|
||||
test('chaque nom de la phrase désigne une colonne présente du tableau, et un seul : sous chaque mode, chaque combinaison des cases, a priori connu ou inconnu', () => {
|
||||
const base = avecPlans(petiteAObjectifs(), PLANS_CONFLIT);
|
||||
let vues = 0;
|
||||
for (const mode of MODES_OBJECTIFS) {
|
||||
for (let masque = 0; masque < 16; masque += 1) {
|
||||
const charge = structuredClone(base);
|
||||
if (mode !== 'places') charge.reglages.modeObjectifs = mode;
|
||||
['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable', 'varierAppartenances'].forEach((cle, rang) => {
|
||||
charge.reglages[cle] = (masque & (1 << rang)) !== 0;
|
||||
});
|
||||
for (const options of [{}, { plafondsAPriori: null }]) {
|
||||
const vue = tableau(evaluer(charge, options));
|
||||
const presentes = new Set(vue.colonnes.map(({ nom }) => nom));
|
||||
const designees = vue.ordre.flat().filter(({ colonne }) => colonne !== null);
|
||||
assert.ok(designees.length >= 1);
|
||||
for (const { valeur, colonne } of designees) assert.ok(presentes.has(colonne), `${mode} ${masque} ${valeur} → ${colonne}`);
|
||||
assert.equal(new Set(designees.map(({ colonne }) => colonne)).size, designees.length, `${mode} ${masque}`);
|
||||
vues += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.equal(vues, 96);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tableau : le sixième groupe, les objectifs des tables et les animateurs (A9, A11)', () => {
|
||||
const noms = (vue) => vue.colonnes.map(({ nom }) => nom);
|
||||
const duGroupe = (vue) => vue.groupes.find(({ cle }) => cle === 'propositions.groupe.objectifs')?.colonnes ?? null;
|
||||
const sansSixieme = (vue) => SIXIEME.every((nom) => !noms(vue).includes(nom));
|
||||
|
||||
// Les colonnes de chaque ligne comparable et du retenu suivent celles de la
|
||||
// vue : les mesures seules, sans le rang, la proposition, les imposés ni
|
||||
// les commandes.
|
||||
function exigerCellulesAlignees(vue) {
|
||||
const mesures = noms(vue).filter((nom) => !['rang', 'proposition', 'imposes', 'commandes'].includes(nom));
|
||||
assert.equal(vue.etendueRaisons, mesures.length + 1);
|
||||
for (const ligne of [...vue.lignes, ...(vue.retenu?.comparable ? [vue.retenu] : [])]) {
|
||||
assert.deepEqual(ligne.cellules.map(({ colonne }) => colonne), mesures, String(ligne.id));
|
||||
}
|
||||
}
|
||||
|
||||
test('sans objectif ni case des animateurs : aucune colonne du groupe ; les vingt et une colonnes et les cinq groupes d’avant', () => {
|
||||
const vue = tableau(evaluer(generee(chargeDemo('petite-conflit'), { graine: 3, nombre: 2, arret: 200 })));
|
||||
assert.deepEqual(noms(vue), COLONNES_PAR_DEFAUT);
|
||||
assert.equal(duGroupe(vue), null);
|
||||
assert.deepEqual(
|
||||
vue.groupes.filter(({ cle }) => cle !== null).map(({ cle }) => cle),
|
||||
['rencontres', 'collisions', 'repetitions', 'diversite', 'manque'].map((nom) => `propositions.groupe.${nom}`),
|
||||
);
|
||||
assert.deepEqual(vue.groupes, GROUPES_COLONNES.filter(({ colonnes }) => !SIXIEME.includes(colonnes[0])));
|
||||
assert.equal(vue.etendueRaisons, 18);
|
||||
exigerCellulesAlignees(vue);
|
||||
});
|
||||
|
||||
test('« le plus de places servies » : les tours hors objectif et le minimum des tours selon l’objectif, après le manque et avant le certificat ; ni les personnes jamais servies ni le plus de tours hors objectif d’une personne', () => {
|
||||
let charge = generee(petiteAObjectifs(), { graine: 3, nombre: 2, arret: 200 });
|
||||
charge = executer(charge, 'retenirProposition', { id: 2 });
|
||||
const table = evaluer(charge);
|
||||
const vue = tableau(table);
|
||||
assert.deepEqual(duGroupe(vue), ['toursHorsObjectif', 'toursSelonObjectif']);
|
||||
assert.ok(!noms(vue).includes('jamaisServies'));
|
||||
assert.ok(!noms(vue).includes('horsObjectifMax'));
|
||||
assert.deepEqual(noms(vue).slice(16, 21), ['ecartItineraireMax', 'ecartAuPlafondAPriori', 'toursHorsObjectif', 'toursSelonObjectif', 'certificat']);
|
||||
exigerCellulesAlignees(vue);
|
||||
const lignes = [...vue.lignes.map((ligne, rang) => [ligne, table.propositions[rang]]), [vue.retenu, table.retenu]];
|
||||
for (const [ligne, source] of lignes) {
|
||||
assert.equal(cellule(ligne, 'toursHorsObjectif').valeur, source.mesures.objectifs.horsObjectif);
|
||||
assert.equal(cellule(ligne, 'toursSelonObjectif').valeur, source.mesures.objectifs.agregat.tous.min);
|
||||
assert.equal(cellule(ligne, 'toursHorsObjectif').cle, null);
|
||||
}
|
||||
});
|
||||
|
||||
test('« chacun au moins une fois » : les personnes jamais servies devant les deux ; « l’équité d’abord » : le plus de tours hors objectif d’une personne', () => {
|
||||
for (const [mode, premiere, lire] of [
|
||||
['chacun', 'jamaisServies', (objectifs) => objectifs.jamaisServies],
|
||||
['minimum', 'horsObjectifMax', (objectifs) => objectifs.horsObjectifMax],
|
||||
]) {
|
||||
const table = evaluer(generee(petiteAObjectifs({ modeObjectifs: mode }), { graine: 3, nombre: 2, arret: 200 }));
|
||||
const vue = tableau(table);
|
||||
assert.deepEqual(duGroupe(vue), [premiere, 'toursHorsObjectif', 'toursSelonObjectif'], mode);
|
||||
exigerCellulesAlignees(vue);
|
||||
vue.lignes.forEach((ligne, rang) => assert.equal(cellule(ligne, premiere).valeur, lire(table.propositions[rang].mesures.objectifs), mode));
|
||||
}
|
||||
});
|
||||
|
||||
test('critère décoché, ou sans objet faute de personne qui corresponde : aucune colonne du groupe', () => {
|
||||
const decoche = tableau(evaluer(generee(petiteAObjectifs({ respecterObjectifs: false, modeObjectifs: 'chacun' }), { graine: 3, nombre: 2, arret: 200 })));
|
||||
assert.ok(sansSixieme(decoche), noms(decoche).join(', '));
|
||||
assert.equal(duGroupe(decoche), null);
|
||||
exigerCellulesAlignees(decoche);
|
||||
const orpheline = petiteAObjectifs({ modeObjectifs: 'chacun' });
|
||||
for (const participant of orpheline.participants) delete participant.etiquettes;
|
||||
const table = evaluer(generee(orpheline, { graine: 3, nombre: 2, arret: 200 }));
|
||||
assert.equal(table.criteres.find(({ nom }) => nom === 'toursHorsObjectif').sansObjet, true);
|
||||
const vue = tableau(table);
|
||||
assert.ok(sansSixieme(vue), noms(vue).join(', '));
|
||||
assert.deepEqual(noms(vue), COLONNES_PAR_DEFAUT);
|
||||
});
|
||||
|
||||
test('la case des animateurs cochée sur la grande : le minimum des animateurs rencontrés et les animateurs non rencontrés ; décochée, rien', () => {
|
||||
const charge = structuredClone(chargeDemo('grande'));
|
||||
charge.reglages.rencontrerAnimateurs = true;
|
||||
const table = structuredClone(evaluer(generee(charge, { graine: 1, nombre: 1, arret: 2000 })));
|
||||
// Trois minimums distincts : la colonne lit celui de toutes les
|
||||
// personnes qui peuvent rencontrer un animateur, jamais celui d'une
|
||||
// population.
|
||||
const { animateurs } = table.propositions[0].mesures;
|
||||
animateurs.agregat = { tous: { min: 0 }, mobiles: { min: 2 }, ancres: { min: 1 } };
|
||||
const vue = tableau(table);
|
||||
assert.deepEqual(duGroupe(vue), ['animateursRencontres', 'animateursNonRencontres']);
|
||||
exigerCellulesAlignees(vue);
|
||||
const [ligne] = vue.lignes;
|
||||
assert.equal(cellule(ligne, 'animateursRencontres').valeur, 0);
|
||||
assert.equal(cellule(ligne, 'animateursNonRencontres').valeur, animateurs.nonRencontres);
|
||||
const sans = tableau(evaluer(generee(chargeDemo('grande'), { graine: 1, nombre: 1, arret: 2000 })));
|
||||
assert.ok(sansSixieme(sans));
|
||||
});
|
||||
|
||||
test('le mode se lit dans les valeurs de l’état des critères, jamais recalculé ; une mesure absente s’écrit « — » (A9)', () => {
|
||||
const table = structuredClone(evaluer(generee(petiteAObjectifs(), { graine: 3, nombre: 2, arret: 200 })));
|
||||
table.criteres.find(({ nom }) => nom === 'toursHorsObjectif').valeurs = ['horsObjectifMax', 'toursHorsObjectif'];
|
||||
table.propositions[0].mesures.objectifs = null;
|
||||
// Trois minimums distincts sur la seconde : la colonne lit celui de
|
||||
// toutes les personnes concernées, jamais celui d'une population.
|
||||
const servie = table.propositions[1];
|
||||
servie.mesures.objectifs.agregat = { tous: { min: 0 }, mobiles: { min: 2 }, ancres: { min: 1 } };
|
||||
const vue = tableau(table);
|
||||
assert.equal(cellule(vue.lignes.find(({ id }) => id === servie.id), 'toursSelonObjectif').valeur, 0);
|
||||
assert.deepEqual(duGroupe(vue), ['horsObjectifMax', 'toursHorsObjectif', 'toursSelonObjectif']);
|
||||
for (const nom of ['horsObjectifMax', 'toursHorsObjectif', 'toursSelonObjectif']) {
|
||||
const vide = cellule(vue.lignes[0], nom);
|
||||
assert.deepEqual([vide.valeur, vide.cle], [null, 'format.vide'], nom);
|
||||
assert.equal(texte(vide), '\u{2014}', nom);
|
||||
assert.equal(typeof cellule(vue.lignes[1], nom).valeur, 'number', nom);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('tableau : la diversité en colonne (§ 5.7)', () => {
|
||||
test('chaque ligne porte A(p), Σ r(p), d(p) et les paires distinctes ; sans affilié rencontré, d(p) s’écrit « — »', () => {
|
||||
const vue = tableau(evaluer(generee(chargeDemo('petite'), { graine: 6, nombre: 2, arret: 300 })));
|
||||
|
|
@ -646,7 +966,7 @@ describe('tableau : le retenu et les placements hors classement (§ 9, § 12.6)'
|
|||
// imposés d'une ligne comparable.
|
||||
const [comparable] = vue.lignes;
|
||||
assert.equal(vue.etendueRaisons, comparable.cellules.length + 1);
|
||||
assert.equal(vue.etendueRaisons, COLONNES.length - 3);
|
||||
assert.equal(vue.etendueRaisons, vue.colonnes.length - 3);
|
||||
});
|
||||
|
||||
test('une exclusion et un ajout, à effectif égal : hors classement aussi', () => {
|
||||
|
|
@ -855,6 +1175,12 @@ describe('les explications des colonnes et des critères (infobulles, § 5.4, §
|
|||
['effectifManque', 'bas'],
|
||||
['ecartItineraireMax', 'bas'],
|
||||
['ecartAuPlafondAPriori', 'bas'],
|
||||
['jamaisServies', 'bas'],
|
||||
['horsObjectifMax', 'bas'],
|
||||
['toursHorsObjectif', 'bas'],
|
||||
['toursSelonObjectif', 'grand'],
|
||||
['animateursRencontres', 'grand'],
|
||||
['animateursNonRencontres', 'bas'],
|
||||
['certificat', null],
|
||||
['imposes', null],
|
||||
['commandes', null],
|
||||
|
|
@ -868,7 +1194,7 @@ describe('les explications des colonnes et des critères (infobulles, § 5.4, §
|
|||
const phrases = (texte) => texte.split(/(?<=\.)\s+(?=[\p{Lu}\u{00AB}])/u);
|
||||
|
||||
test('chaque colonne de COLONNES, sans exception, porte la clé de son explication : elle existe, compte une à trois phrases, et diffère de l’en-tête et des autres explications', () => {
|
||||
assert.equal(COLONNES.length, 21);
|
||||
assert.equal(COLONNES.length, 27);
|
||||
assert.deepEqual(COLONNES.map(({ nom }) => nom), [...SENS.keys()]);
|
||||
const textes = new Set();
|
||||
for (const { nom, cle, explication } of COLONNES) {
|
||||
|
|
@ -896,26 +1222,49 @@ describe('les explications des colonnes et des critères (infobulles, § 5.4, §
|
|||
}
|
||||
});
|
||||
|
||||
test('chaque critère, et l’identifiant croissant, partage l’explication de la colonne qu’il désigne ; seule l’explication d’un critère le dit critère du classement', () => {
|
||||
const criteres = new Set(CRITERES.map(colonneDuCritere));
|
||||
assert.equal(criteres.size, 5);
|
||||
test('chaque valeur qui classe, et l’identifiant croissant, partage l’explication de la colonne qu’elle désigne ; seule l’explication d’une valeur qui classe la dit critère du classement', () => {
|
||||
const valeurs = ['ecartAuPlafondAPrioriMax', 'excedentCollisions', 'collisionsCumulees', 'jamaisServies', 'horsObjectifMax']
|
||||
.concat(['toursHorsObjectif', 'rencontresRepeteesChoisies', 'retoursChoisis', 'animateursNonRencontres', 'redondance']);
|
||||
const criteres = new Set(valeurs.map(colonneDeValeur));
|
||||
assert.equal(criteres.size, 10);
|
||||
for (const { nom } of COLONNES) {
|
||||
const texte = t(explicationDeColonne(nom));
|
||||
if (criteres.has(nom)) assert.match(texte, /critère du classement/i, nom);
|
||||
else assert.doesNotMatch(texte, /(?<!pas un )critère du classement/i, nom);
|
||||
}
|
||||
assert.match(t(explicationDeColonne(colonneDuCritere('identifiant'))), /départage/, 'identifiant');
|
||||
assert.match(t(explicationDeColonne(colonneDeValeur('identifiant'))), /départage/, 'identifiant');
|
||||
for (const options of [{}, { plafondsAPriori: null }]) {
|
||||
const vue = tableau(evaluer(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT), options));
|
||||
const designants = vue.ordre.flat().filter(({ colonne }) => colonne !== null);
|
||||
assert.ok(designants.length >= 5);
|
||||
for (const { critere, colonne } of designants) {
|
||||
assert.equal(explicationDeColonne(colonne), explicationDeColonne(colonneDuCritere(critere)), critere);
|
||||
assert.ok(t(explicationDeColonne(colonne)).length > 0, critere);
|
||||
for (const { valeur, colonne } of designants) {
|
||||
assert.equal(explicationDeColonne(colonne), explicationDeColonne(colonneDeValeur(valeur)), valeur);
|
||||
assert.ok(t(explicationDeColonne(colonne)).length > 0, valeur);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('l’explication d’une colonne que désigne une valeur qui classe ne fixe aucun rang : la place vient de l’ordre écrit au-dessus du tableau', () => {
|
||||
const valeurs = new Set();
|
||||
for (const mode of MODES_OBJECTIFS) {
|
||||
for (const nouveauxVoisins of [true, false]) {
|
||||
for (const nom of REGISTRE) for (const valeur of valeursQuiClassent(nom, { nouveauxVoisins }, mode)) valeurs.add(valeur);
|
||||
}
|
||||
}
|
||||
assert.equal(valeurs.size, 10);
|
||||
for (const valeur of valeurs) {
|
||||
const texte = t(explicationDeColonne(colonneDeValeur(valeur)));
|
||||
assert.doesNotMatch(texte, /critère du classement (?:après|avant)|(?:premier|dernier) critère/i, valeur);
|
||||
assert.match(texte, /l'ordre écrit au-dessus du tableau/, valeur);
|
||||
}
|
||||
});
|
||||
|
||||
test('l’excédent de collisions dit son rang par défaut et pourquoi : passer la soirée avec le même collègue est pire que croiser six collègues une fois (§ 5.4)', () => {
|
||||
const texte = t(explicationDeColonne('excedentCollisions'));
|
||||
assert.match(texte, /par défaut avant les collisions cumulées/);
|
||||
assert.match(texte, /le même collègue est pire que croiser six collègues une fois/);
|
||||
});
|
||||
|
||||
test('explicationDeColonne lève pour un nom qui n’est pas une colonne', () => {
|
||||
assert.equal(explicationDeColonne('rang'), 'propositions.explication.rang');
|
||||
assert.throws(() => explicationDeColonne('redondance'), TypeError);
|
||||
|
|
@ -951,7 +1300,7 @@ describe('GROUPES_COLONNES : les en-têtes groupés du tableau', () => {
|
|||
assert.deepEqual(seules.map(({ colonnes }) => colonnes), [['rang'], ['proposition'], ['certificat'], ['imposes'], ['commandes']]);
|
||||
});
|
||||
|
||||
test('cinq groupes de mesures voisines, chacun sous une clé « propositions.groupe. » qui a son texte, distinct des en-têtes de colonne', () => {
|
||||
test('six groupes de mesures voisines, chacun sous une clé « propositions.groupe. » qui a son texte, distinct des en-têtes de colonne', () => {
|
||||
const groupes = GROUPES_COLONNES.filter(({ cle }) => cle !== null);
|
||||
assert.deepEqual(
|
||||
groupes.map(({ colonnes }) => colonnes),
|
||||
|
|
@ -961,8 +1310,10 @@ describe('GROUPES_COLONNES : les en-têtes groupés du tableau', () => {
|
|||
['rencontresRepetees', 'maxRencontresPaire', 'retoursChoisis'],
|
||||
['appartenancesVues', 'totalRedondance', 'diversite'],
|
||||
['manqueMax', 'effectifManque', 'ecartItineraireMax', 'ecartAuPlafondAPriori'],
|
||||
SIXIEME,
|
||||
],
|
||||
);
|
||||
assert.equal(groupes.at(-1).cle, 'propositions.groupe.objectifs');
|
||||
const enTetes = new Set(COLONNES.map(({ cle }) => t(cle)));
|
||||
for (const { cle } of groupes) {
|
||||
assert.match(cle, /^propositions\.groupe\.[a-z]+$/u);
|
||||
|
|
|
|||
48
src/interface/tables/ChoixRegle.svelte
Normal 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>
|
||||
|
|
@ -20,13 +20,40 @@
|
|||
// page. Une frappe dans le champ, ou Échap, retire le
|
||||
// complément d'un refus avec le refus que le champ retire.
|
||||
//
|
||||
// Supprimer une table qui porte une réservation ou un titre ouvre la
|
||||
// fenêtre à confirmer, qui les nomme (bilanSuppressionTable) ; une table
|
||||
// nue part sans fenêtre.
|
||||
import { getContext } from 'svelte';
|
||||
// Supprimer une table qui porte une réservation, un titre ou un objectif
|
||||
// ouvre la fenêtre à confirmer, qui les nomme (bilanSuppressionTable) ;
|
||||
// une table nue part sans fenêtre.
|
||||
//
|
||||
// Les objectifs d'une table (§ 4.5, D3, D19) se posent sous sa ligne :
|
||||
// l'objectif de la soirée, puis le bouton « Objectifs par tour », qui
|
||||
// déplie un objectif par tour et compte ceux qui existent (objectifs.js).
|
||||
// Chaque objectif a son champ d'étiquettes, que le datalist des étiquettes
|
||||
// connues complète, et le choix de sa règle (ChoixRegle) — le réglage
|
||||
// général, « au moins une (OU) », « toutes (ET) ». Une saisie validée
|
||||
// part à poserObjectif sous la règle choisie : celle de l'objectif posé,
|
||||
// ou, pour un objectif neuf, celle que son choix tient sans rien écrire,
|
||||
// le réglage général à défaut ; un seul geste, une seule entrée. Vider le
|
||||
// champ d'un objectif posé est refusé contre lui, avec le remède
|
||||
// « Retirer l'objectif ». Un objectif posé montre en plus son état en
|
||||
// mots, « suit la règle par défaut » ou « règle propre », et « Retirer
|
||||
// l'objectif », sans fenêtre : un retour à l'instant précédent le rend, et
|
||||
// le focus passe à son champ. Ses avis — personne ne lui correspond, une
|
||||
// étiquette que personne ne porte — se lisent contre lui, tant qu'ils
|
||||
// tiennent ; une orthographe retenue au dernier geste aussi, tant que sa
|
||||
// révision est la courante. Un refus de la séance se pose contre le
|
||||
// champ, par Champ, ou contre le choix de la règle, qui le désigne par
|
||||
// aria-describedby et montre alors la règle de la charge, ou le choix
|
||||
// tenu d'un objectif neuf.
|
||||
//
|
||||
// « Exporter les objectifs » (§ 10.4) remet les octets d'exporterObjectifs
|
||||
// à facade.enregistrerSous, sous le nom ‹base›-objectifs.csv
|
||||
// (nomExportObjectifs), comme l'export des participants : sortir n'est pas
|
||||
// modifier, elle part en lecture et sur un plan bloqué.
|
||||
import { getContext, tick } from 'svelte';
|
||||
import { on } from 'svelte/events';
|
||||
import { bilanSuppressionTable } from '../../application/bilans.js';
|
||||
import { lireEntier } from '../../application/capacite.js';
|
||||
import { exporterObjectifs } from '../../application/csv.js';
|
||||
import { ErreurCommande } from '../../application/erreurs.js';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import Champ from '../primitives/Champ.svelte';
|
||||
|
|
@ -34,7 +61,18 @@
|
|||
import Confirmation from '../primitives/Confirmation.svelte';
|
||||
import Icone from '../primitives/Icone.svelte';
|
||||
import Pastille from '../primitives/Pastille.svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import { effetDe } from '../raccourcis.js';
|
||||
import ChoixRegle from './ChoixRegle.svelte';
|
||||
import {
|
||||
basculerTable,
|
||||
etiquettesConnues,
|
||||
nomExportObjectifs,
|
||||
objectifsDeLaTable,
|
||||
optionsRegle,
|
||||
regleChoisie,
|
||||
regleParDefaut,
|
||||
} from './objectifs.js';
|
||||
import {
|
||||
annonceSuppression,
|
||||
avertissementsNommes,
|
||||
|
|
@ -51,9 +89,27 @@
|
|||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
const facade = getContext('facade');
|
||||
const uid = $props.id();
|
||||
const idConnues = `${uid}-etiquettes`;
|
||||
|
||||
const tables = $derived(tablesParNumero(charge));
|
||||
const comptes = $derived(comptesTables(charge));
|
||||
const connues = $derived(etiquettesConnues(charge));
|
||||
const options = $derived(optionsRegle(regleParDefaut(charge)).map(({ valeur, cle, details }) => ({ valeur, libelle: t(cle, details) })));
|
||||
|
||||
// Les tables dont les objectifs par tour sont dépliés ; par clé
|
||||
// d'objectif, les orthographes retenues de son dernier geste
|
||||
// { revision, avertissements }, le refus de son choix de règle, et, pour
|
||||
// un objectif neuf, la valeur que son choix tient jusqu'à la saisie.
|
||||
let ouvertes = $state.raw(new Set());
|
||||
let retenues = $state({});
|
||||
let refusRegle = $state({});
|
||||
let reglesNeuves = $state({});
|
||||
|
||||
// La valeur du choix de règle d'un objectif : la sienne quand il existe,
|
||||
// celle que tient le choix d'un objectif neuf sinon, vide à défaut.
|
||||
const valeurRegle = (vue) => (vue.existe ? vue.regle : (reglesNeuves[vue.cle] ?? ''));
|
||||
|
||||
// Le complément de chaque ligne, par identifiant de table :
|
||||
// { revision, retires, avertissements }. La fenêtre de suppression
|
||||
|
|
@ -114,6 +170,77 @@
|
|||
suppression = null;
|
||||
}
|
||||
|
||||
// Les orthographes retenues du dernier geste sur l'objectif de clé donnée,
|
||||
// tant que sa révision est la courante.
|
||||
const retenuesDe = (cle) => {
|
||||
const note = retenues[cle];
|
||||
return note !== undefined && note.revision === revision ? note.avertissements : [];
|
||||
};
|
||||
|
||||
// Pose l'objectif de la table au tour de vue : la saisie texte, sous la
|
||||
// règle donnée, par défaut celle de son choix (valeurRegle). Posé, il
|
||||
// porte sa règle : le choix tenu d'un objectif neuf s'oublie.
|
||||
async function poser(table, vue, texte, regle = regleChoisie(valeurRegle(vue))) {
|
||||
delete retenues[vue.cle];
|
||||
const rendu = await seance.executer('poserObjectif', { table: table.id, tour: vue.tour, etiquettes: texte, regle });
|
||||
delete reglesNeuves[vue.cle];
|
||||
retenues[vue.cle] = {
|
||||
revision: seance.etat().evenement.revision,
|
||||
avertissements: rendu.avertissements.filter(({ code }) => code === 'ORTHOGRAPHE_RETENUE'),
|
||||
};
|
||||
}
|
||||
|
||||
// Le choix d'une règle : l'objectif posé se repose, mêmes étiquettes,
|
||||
// sous elle, et le sélecteur montre ensuite la règle de la charge, écrite
|
||||
// ou non. Pour un objectif neuf, le choix est tenu sans rien écrire ; le
|
||||
// refus courant de la séance le refuse aussitôt, comme la première
|
||||
// frappe d'un champ, et le choix rend sa valeur précédente.
|
||||
async function choisirRegle(evenement, table, vue) {
|
||||
const choix = evenement.currentTarget;
|
||||
delete refusRegle[vue.cle];
|
||||
if (!vue.existe) {
|
||||
const refus = seance.etat().refus;
|
||||
if (refus === null) reglesNeuves[vue.cle] = choix.value;
|
||||
else {
|
||||
refusRegle[vue.cle] = { code: refus.code, details: refus.details ?? {} };
|
||||
choix.value = valeurRegle(vue);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await poser(table, vue, vue.texte, regleChoisie(choix.value));
|
||||
} catch (erreur) {
|
||||
if (typeof erreur?.code !== 'string') throw erreur;
|
||||
refusRegle[vue.cle] = { code: erreur.code, details: erreur.details ?? {} };
|
||||
} finally {
|
||||
const courante = seance.etat().charge?.tables.find(({ id }) => id === table.id);
|
||||
choix.value = courante?.objectifs?.find((objectif) => objectif.tour === vue.tour)?.regle ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
// Retire l'objectif de vue ; son retrait ôte le bouton cliqué, et le
|
||||
// focus passe au champ de l'objectif, qui reste.
|
||||
async function retirer(table, vue) {
|
||||
await seance.executer('retirerObjectif', { table: table.id, tour: vue.tour });
|
||||
await tick();
|
||||
document.getElementById(`${uid}-objectif-${vue.cle}`)?.querySelector('input')?.focus();
|
||||
}
|
||||
|
||||
async function exporterLesObjectifs() {
|
||||
const { evenement, charge: courante } = seance.etat();
|
||||
await facade.enregistrerSous(nomExportObjectifs(evenement.base), exporterObjectifs(courante.tables));
|
||||
}
|
||||
|
||||
// Le champ d'un objectif propose les étiquettes connues : son entrée
|
||||
// porte la liste du datalist de la carte.
|
||||
const avecConnues = (element) => {
|
||||
for (const entree of element.querySelectorAll('input')) entree.setAttribute('list', idConnues);
|
||||
};
|
||||
|
||||
// La saisie d'étiquettes se lit telle quelle : poserObjectif la découpe,
|
||||
// la met en forme et la refuse.
|
||||
const lireTexte = (texte) => ({ valeur: texte });
|
||||
|
||||
// Une frappe ou Échap dans le champ de la table id retire le complément
|
||||
// de son refus : le champ retire le refus au même instant.
|
||||
const suivreSaisie = (id) => (element) => {
|
||||
|
|
@ -129,12 +256,76 @@
|
|||
};
|
||||
</script>
|
||||
|
||||
<!-- Un objectif d'une table, celui de la soirée ou d'un tour (vue). Sa
|
||||
portée se pose en littéral, une branche par valeur : la garde des
|
||||
désignations en tient la liste fermée. -->
|
||||
{#snippet objectif(table, vue)}
|
||||
{#if vue.tour === null}
|
||||
<div class="objectif" id={`${uid}-objectif-${vue.cle}`} data-objectif={vue.cle} data-portee="tous">
|
||||
{@render contenuObjectif(table, vue)}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="objectif" id={`${uid}-objectif-${vue.cle}`} data-objectif={vue.cle} data-portee="tour">
|
||||
{@render contenuObjectif(table, vue)}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet contenuObjectif(table, vue)}
|
||||
{@const refusDuChoix = refusRegle[vue.cle] ?? null}
|
||||
{@const idRegle = `${uid}-regle-${vue.cle}`}
|
||||
{@const idRefusRegle = `${idRegle}-refus`}
|
||||
<div class="ligne-objectif">
|
||||
<div class="saisie-objectif" {@attach avecConnues}>
|
||||
<Champ
|
||||
cle={`objectif-${vue.cle}`}
|
||||
etiquette={vue.tour === null ? 'tables.objectif.soiree' : 'tables.objectif.tour'}
|
||||
details={{ tour: vue.tour }}
|
||||
valeur={vue.texte}
|
||||
lire={lireTexte}
|
||||
executer={(texte) => poser(table, vue, texte)}
|
||||
/>
|
||||
</div>
|
||||
<div class="regle-objectif">
|
||||
<label for={idRegle}>{t('tables.objectif.regle')}</label>
|
||||
<ChoixRegle
|
||||
id={idRegle}
|
||||
champ={`regle-${vue.cle}`}
|
||||
valeur={valeurRegle(vue)}
|
||||
{options}
|
||||
idRefus={refusDuChoix === null ? null : idRefusRegle}
|
||||
surChoix={(evenement) => choisirRegle(evenement, table, vue)}
|
||||
/>
|
||||
</div>
|
||||
{#if vue.existe}
|
||||
<span class="etat-regle"><Pastille sorte="statut" valeur={vue.etat.ton} libelle={t(vue.etat.cle)} /></span>
|
||||
<Commande cle="tables.objectif.retirer" nom="retirerObjectif" executer={() => retirer(table, vue)} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if refusDuChoix !== null}
|
||||
<Refus id={idRefusRegle} refus={refusDuChoix} lever={() => delete refusRegle[vue.cle]} />
|
||||
{/if}
|
||||
{#each [...retenuesDe(vue.cle), ...vue.avis] as avis, rang (rang)}
|
||||
<div class="message avis" data-avertissement={avis.code} role="status">
|
||||
<span class="signe"><Icone nom="triangle" /></span>
|
||||
<div class="texte"><p>{t(avis.code, avis.details)}</p></div>
|
||||
</div>
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
<Carte titre={t('tables.liste.titre')} sousTitre={t('tables.liste.comptes', comptes)} niveau={3} plein>
|
||||
{#snippet actions()}
|
||||
<Commande cle="tables.creer.ronde" nom="creerTableRonde" executer={() => seance.executer('creerTable', { forme: 'ronde' })} />
|
||||
<Commande cle="tables.creer.carree" nom="creerTableCarree" executer={() => seance.executer('creerTable', { forme: 'carree' })} />
|
||||
<Commande cle="tables.objectifs.exporter" nom="exporterObjectifs" executer={exporterLesObjectifs} />
|
||||
{/snippet}
|
||||
|
||||
<datalist id={idConnues}>
|
||||
{#each connues as motCle (motCle)}
|
||||
<option value={motCle}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
|
||||
{#if tables.length === 0}
|
||||
<p class="aucune">{t('tables.liste.aucune')}</p>
|
||||
{:else}
|
||||
|
|
@ -142,6 +333,9 @@
|
|||
{#each tables as table (table.id)}
|
||||
{@const etat = etatTable(table, charge.evenement.siegesParDefaut)}
|
||||
{@const complement = complementDe(table.id)}
|
||||
{@const vues = objectifsDeLaTable(charge, table)}
|
||||
{@const ouverte = ouvertes.has(table.id)}
|
||||
{@const idTours = `${uid}-tours-${table.id}`}
|
||||
<li data-table={table.id}>
|
||||
<div class="entete">
|
||||
<span class="numero"><span>{t('tables.liste.table')}</span> <span>{table.numero}</span></span>
|
||||
|
|
@ -194,6 +388,27 @@
|
|||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
<div class="objectifs">
|
||||
{@render objectif(table, vues.soiree)}
|
||||
<button
|
||||
type="button"
|
||||
class="deplier"
|
||||
data-commande="basculerObjectifsTours"
|
||||
aria-expanded={ouverte ? 'true' : 'false'}
|
||||
aria-controls={ouverte ? idTours : undefined}
|
||||
onclick={() => (ouvertes = basculerTable(ouvertes, table.id))}
|
||||
>
|
||||
<span class="chevron"><Icone nom="chevron" /></span>
|
||||
<span>{t('tables.objectifs.parTour', { nombre: vues.parTour })}</span>
|
||||
</button>
|
||||
{#if ouverte}
|
||||
<ul class="par-tour" id={idTours}>
|
||||
{#each vues.tours as vue (vue.cle)}
|
||||
<li>{@render objectif(table, vue)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
|
@ -319,4 +534,82 @@
|
|||
.texte p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Les objectifs d'une table : sous sa ligne, sur toute la largeur ; un
|
||||
objectif par rangée, son champ, son choix de règle, son état et son
|
||||
retrait côte à côte tant qu'elle les tient, ses messages dessous. */
|
||||
.objectifs {
|
||||
display: flex;
|
||||
flex-basis: 100%;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
.objectif {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
.ligne-objectif {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--espace-2) var(--espace-4);
|
||||
}
|
||||
|
||||
.regle-objectif {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
.etat-regle {
|
||||
display: flex;
|
||||
align-self: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Le bouton qui déplie les objectifs par tour : un texte de l'encre de la
|
||||
page, sans fond, et son chevron, tourné vers le bas quand la liste est
|
||||
dépliée. */
|
||||
.deplier {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
align-items: center;
|
||||
gap: var(--espace-1);
|
||||
min-height: var(--hauteur-cible);
|
||||
padding: 0 var(--espace-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--rayon-2);
|
||||
background: none;
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
font-size: var(--taille-texte);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.deplier:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.deplier[aria-expanded='true'] .chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.par-tour {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
margin: 0;
|
||||
padding: 0 0 0 var(--espace-5);
|
||||
list-style: none;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
67
src/interface/tables/RegleObjectifs.svelte
Normal 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>
|
||||
|
|
@ -2,9 +2,10 @@
|
|||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La section des tables et des tours (§ 4, § 6, § 2.1 étapes 4 et 5) : la
|
||||
// carte des réglages — le nombre de places par défaut et l'énoncé de son
|
||||
// changement, le nombre de tours —, puis la carte de la liste des tables.
|
||||
// La section des tables et des tours (§ 4, § 4.5, § 6, § 2.1 étapes 4 et
|
||||
// 5) : la carte des réglages — le nombre de places par défaut et l'énoncé
|
||||
// de son changement, le nombre de tours, la règle par défaut des
|
||||
// objectifs —, puis la carte de la liste des tables et de leurs objectifs.
|
||||
// Elle lit la charge et la révision dans l'état observable de la séance
|
||||
// et les donne à ses parties ; chaque geste passe par la séance, et
|
||||
// chaque refus se pose là où il a eu lieu.
|
||||
|
|
@ -12,6 +13,7 @@
|
|||
import Carte from '../primitives/Carte.svelte';
|
||||
import Defaut from './Defaut.svelte';
|
||||
import ListeTables from './ListeTables.svelte';
|
||||
import RegleObjectifs from './RegleObjectifs.svelte';
|
||||
import Tours from './Tours.svelte';
|
||||
|
||||
const t = getContext('t');
|
||||
|
|
@ -26,6 +28,7 @@
|
|||
<div class="reglages">
|
||||
<Defaut charge={$seance.charge} revision={$seance.evenement.revision} />
|
||||
<Tours charge={$seance.charge} />
|
||||
<RegleObjectifs charge={$seance.charge} />
|
||||
</div>
|
||||
</Carte>
|
||||
<ListeTables charge={$seance.charge} revision={$seance.evenement.revision} />
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
|
|||
import { flushSync, mount, tick, unmount } from 'svelte';
|
||||
import '../jetons.css';
|
||||
import { controlerCapacite } from '../../application/capacite.js';
|
||||
import { exporterObjectifs } from '../../application/csv.js';
|
||||
import { creerHorlogePilotee } from '../../application/horloge.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { nomAffiche } from '../../application/personnes.js';
|
||||
|
|
@ -121,8 +122,16 @@ afterEach(() => {
|
|||
|
||||
// Le fichier d'état de la charge posé dans le système simulé, ouvert par une
|
||||
// séance, en écriture sauf mode 'lecture' ; la section montée au début du
|
||||
// corps, avec les contextes de l'application.
|
||||
// corps, avec les contextes de l'application. La façade ne sait
|
||||
// qu'enregistrer : chaque appel d'enregistrerSous se range dans enregistres.
|
||||
async function preparer(charge, { mode = 'ecriture' } = {}) {
|
||||
const enregistres = [];
|
||||
const facade = {
|
||||
async enregistrerSous(nomPropose, octets) {
|
||||
enregistres.push({ nomPropose, octets });
|
||||
return nomPropose;
|
||||
},
|
||||
};
|
||||
const fs = creerFichiersSimules();
|
||||
fs.deposerDossier('documents');
|
||||
fs.deposer('documents', `${BASE}${SUFFIXES.etat}`, serialiser(charge, { revision: 1, produitVersion: VERSION.affichee }));
|
||||
|
|
@ -149,13 +158,14 @@ async function preparer(charge, { mode = 'ecriture' } = {}) {
|
|||
['t', t],
|
||||
['horloge', horloge],
|
||||
['ui', ui],
|
||||
['facade', facade],
|
||||
]);
|
||||
const cible = document.createElement('div');
|
||||
document.body.prepend(cible);
|
||||
const composant = mount(SectionTables, { target: cible, props: {}, context: contexte });
|
||||
flushSync();
|
||||
montes.push({ composant, cible });
|
||||
return { fs, seance, ui, cible };
|
||||
return { fs, seance, ui, cible, enregistres };
|
||||
}
|
||||
|
||||
const section = (m) => m.cible.querySelector('[data-section="tables"]');
|
||||
|
|
@ -794,3 +804,302 @@ describe('l’habillage : cartes, pastilles, messages', () => {
|
|||
expect(composantes(getComputedStyle(avis.querySelector('p')).color)).toEqual(jeton('--couleur-page-texte'));
|
||||
});
|
||||
});
|
||||
|
||||
// --- Les objectifs des tables (§ 4.5, § 10.4, D3, D19) ---------------------------
|
||||
|
||||
// L'objectif d'une table à l'écran, par sa clé : ‹table› pour la soirée,
|
||||
// ‹table›-‹tour› pour un tour ; son champ d'étiquettes et son choix de règle.
|
||||
const zoneObjectif = (m, cle) => section(m).querySelector(`[data-objectif="${cle}"]`);
|
||||
const champObjectif = (m, cle) => zoneObjectif(m, cle).querySelector(`input[data-champ="objectif-${cle}"]`);
|
||||
const choixRegle = (m, cle) => zoneObjectif(m, cle).querySelector(`select[data-champ="regle-${cle}"]`);
|
||||
const regleDefaut = (m) => section(m).querySelector('[data-regle-objectifs] select[data-champ="regleObjectifs"]');
|
||||
const objectifs = (m, id) => charge(m).tables.find((table) => table.id === id).objectifs;
|
||||
// Le texte d'un objectif, sans les blancs du balisage.
|
||||
const texteObjectif = (m, cle) => zoneObjectif(m, cle).textContent.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Choisit au clavier la valeur d'un sélecteur, un cran de flèche à la fois,
|
||||
// la file de la séance passée à chaque cran : chaque cran est un choix. Un
|
||||
// choix refusé rend la valeur enregistrée : au plus autant de crans que
|
||||
// d'options.
|
||||
async function choisirAuClavier(m, choix, valeur) {
|
||||
choix.focus();
|
||||
const cible = [...choix.options].findIndex((option) => option.value === valeur);
|
||||
expect(cible, `option ${valeur}`).toBeGreaterThanOrEqual(0);
|
||||
for (let cran = 0; cran < choix.options.length && choix.selectedIndex !== cible; cran += 1) {
|
||||
await touche(choix.selectedIndex < cible ? 'ArrowDown' : 'ArrowUp');
|
||||
await attendre(m);
|
||||
}
|
||||
}
|
||||
|
||||
// Deux tables, trois tours, quatre personnes : Iris Ombrelle porte
|
||||
// « finance », Théo Pervenche « cuisine ».
|
||||
function chargeObjectifs(options = {}) {
|
||||
const donnees = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 4, tours: 3, ...options });
|
||||
donnees.participants[0].etiquettes = ['finance'];
|
||||
donnees.participants[1].etiquettes = ['cuisine'];
|
||||
return donnees;
|
||||
}
|
||||
|
||||
describe('la règle par défaut des objectifs (§ 2.1 étape 4, § 4.5, D3)', () => {
|
||||
test('dans la carte des réglages, « au moins une (OU) » ; « toutes (ET) » choisie au clavier : une entrée, la règle propre d’un objectif ne change pas', async () => {
|
||||
const donnees = chargeObjectifs();
|
||||
donnees.tables[0].objectifs = [{ portee: 'tous', tour: null, regle: 'ou', etiquettes: ['finance'] }];
|
||||
const m = await preparer(donnees);
|
||||
const [reglages] = section(m).querySelectorAll('section[aria-labelledby]');
|
||||
expect(reglages.contains(regleDefaut(m))).toBe(true);
|
||||
expect(regleDefaut(m).value).toBe('ou');
|
||||
expect(regleDefaut(m).selectedOptions[0].textContent).toBe(t('tables.regle.ou'));
|
||||
expect(section(m).querySelector(`label[for="${regleDefaut(m).id}"]`).textContent).toBe(t('tables.regle.etiquette'));
|
||||
const r0 = revision(m);
|
||||
await choisirAuClavier(m, regleDefaut(m), 'et');
|
||||
expect(revision(m)).toBe(r0 + 1);
|
||||
expect(charge(m).reglages.regleObjectifs).toBe('et');
|
||||
expect(regleDefaut(m).value).toBe('et');
|
||||
expect(objectifs(m, 1)[0].regle).toBe('ou');
|
||||
expect(choixRegle(m, '1').value).toBe('ou');
|
||||
expect(choixRegle(m, '1').options[0].textContent).toBe(t('tables.objectif.regleDefaut', { regle: 'et' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('l’objectif d’une table (§ 4.5, D3, D19)', () => {
|
||||
test('saisi « finance, cuisine » : une entrée, la règle suit le défaut et le dit ; « au moins une (OU) » choisie sous le défaut « ou » : l’état dit « règle propre », jamais celui du défaut', async () => {
|
||||
const m = await preparer(chargeObjectifs());
|
||||
expect(zoneObjectif(m, '1').dataset.portee).toBe('tous');
|
||||
expect(choixRegle(m, '1').value).toBe('');
|
||||
expect(commande(zoneObjectif(m, '1'), 'retirerObjectif')).toBeNull();
|
||||
const r0 = revision(m);
|
||||
await saisir(m, champObjectif(m, '1'), 'finance, cuisine');
|
||||
expect(revision(m)).toBe(r0 + 1);
|
||||
expect(objectifs(m, 1)).toEqual([{ portee: 'tous', tour: null, regle: null, etiquettes: ['cuisine', 'finance'] }]);
|
||||
expect(champObjectif(m, '1').value).toBe('cuisine, finance');
|
||||
expect(choixRegle(m, '1').value).toBe('');
|
||||
const suit = texteObjectif(m, '1');
|
||||
expect(suit).toContain(t('tables.objectif.suitDefaut'));
|
||||
expect(suit).not.toContain(t('tables.objectif.reglePropre'));
|
||||
|
||||
await choisirAuClavier(m, choixRegle(m, '1'), 'ou');
|
||||
expect(revision(m)).toBe(r0 + 2);
|
||||
expect(objectifs(m, 1)[0].regle).toBe('ou');
|
||||
expect(charge(m).reglages.regleObjectifs).toBeUndefined();
|
||||
const propre = texteObjectif(m, '1');
|
||||
expect(propre).toContain(t('tables.objectif.reglePropre'));
|
||||
expect(propre).not.toContain(t('tables.objectif.suitDefaut'));
|
||||
});
|
||||
|
||||
test('un objectif neuf : sa règle se choisit avant ses étiquettes, rien d’écrit ; la saisie le pose sous elle, une seule entrée', async () => {
|
||||
const m = await preparer(chargeObjectifs());
|
||||
expect(section(m).querySelector(`label[for="${choixRegle(m, '1').id}"]`).textContent).toBe(t('tables.objectif.regle'));
|
||||
expect(texteObjectif(m, '1')).not.toContain(t('tables.objectif.suitDefaut'));
|
||||
const r0 = revision(m);
|
||||
await choisirAuClavier(m, choixRegle(m, '1'), 'et');
|
||||
expect(revision(m)).toBe(r0);
|
||||
expect(objectifs(m, 1)).toBeUndefined();
|
||||
expect(choixRegle(m, '1').value).toBe('et');
|
||||
expect(document.activeElement).toBe(choixRegle(m, '1'));
|
||||
await saisir(m, champObjectif(m, '1'), 'finance, cuisine');
|
||||
expect(revision(m)).toBe(r0 + 1);
|
||||
expect(objectifs(m, 1)).toEqual([{ portee: 'tous', tour: null, regle: 'et', etiquettes: ['cuisine', 'finance'] }]);
|
||||
expect(choixRegle(m, '1').value).toBe('et');
|
||||
expect(texteObjectif(m, '1')).toContain(t('tables.objectif.reglePropre'));
|
||||
// L'autre table garde son choix vide : le choix d'un objectif neuf est
|
||||
// le sien.
|
||||
expect(choixRegle(m, '2').value).toBe('');
|
||||
});
|
||||
|
||||
test('les avis contre l’objectif : personne ne correspond, une étiquette que personne ne porte, sa voisine nommée ; le champ propose les étiquettes connues', async () => {
|
||||
const m = await preparer(chargeObjectifs());
|
||||
const liste = document.getElementById(champObjectif(m, '2').getAttribute('list'));
|
||||
expect(liste.localName).toBe('datalist');
|
||||
expect([...liste.options].map((option) => option.value)).toEqual(['cuisine', 'finance']);
|
||||
await saisir(m, champObjectif(m, '2'), 'Finances');
|
||||
const avis = [...zoneObjectif(m, '2').querySelectorAll('[data-avertissement]')].map((element) => element.dataset.avertissement);
|
||||
expect(avis).toEqual(['OBJECTIF_SANS_CORRESPONDANT', 'ETIQUETTE_ORPHELINE']);
|
||||
expect(texteObjectif(m, '2')).toContain(t('ETIQUETTE_ORPHELINE', { etiquette: 'Finances', voisine: null }));
|
||||
expect([...liste.options].map((option) => option.value)).toEqual(['Finances', 'cuisine', 'finance']);
|
||||
// Théo Pervenche visé : l'objectif sert quelqu'un, les avis tombent.
|
||||
await saisir(m, champObjectif(m, '2'), 'cuisine');
|
||||
expect(zoneObjectif(m, '2').querySelectorAll('[data-avertissement]')).toHaveLength(0);
|
||||
|
||||
// Un objectif écrit « Finance » à la main, quand Iris Ombrelle porte
|
||||
// « finance » : l'orpheline nomme sa voisine de même clé.
|
||||
const donnees = chargeObjectifs();
|
||||
donnees.tables[1].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['Finance'] }];
|
||||
const n = await preparer(donnees);
|
||||
expect(texteObjectif(n, '2')).toContain(t('ETIQUETTE_ORPHELINE', { etiquette: 'Finance', voisine: 'finance' }));
|
||||
});
|
||||
|
||||
test('une orthographe retenue se dit contre le champ : « FINANCE » saisi reçoit « finance »', async () => {
|
||||
const m = await preparer(chargeObjectifs());
|
||||
await saisir(m, champObjectif(m, '1'), 'FINANCE');
|
||||
expect(objectifs(m, 1)[0].etiquettes).toEqual(['finance']);
|
||||
expect(champObjectif(m, '1').value).toBe('finance');
|
||||
const retenue = zoneObjectif(m, '1').querySelector('[data-avertissement="ORTHOGRAPHE_RETENUE"]');
|
||||
expect(retenue).not.toBeNull();
|
||||
expect(retenue.textContent).toContain(t('ORTHOGRAPHE_RETENUE', { saisie: 'FINANCE', retenue: 'finance' }));
|
||||
});
|
||||
|
||||
test('« Retirer l’objectif » : sans fenêtre, une entrée, le champ se vide ; vider le champ d’un objectif posé est refusé contre lui, et son remède le retire', async () => {
|
||||
const donnees = chargeObjectifs();
|
||||
donnees.tables[0].objectifs = [{ portee: 'tous', tour: null, regle: 'et', etiquettes: ['finance'] }];
|
||||
donnees.tables[1].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['cuisine'] }];
|
||||
const m = await preparer(donnees);
|
||||
const r0 = revision(m);
|
||||
await cliquer(m, commande(zoneObjectif(m, '1'), 'retirerObjectif'));
|
||||
expect(fenetre()).toBeNull();
|
||||
expect(revision(m)).toBe(r0 + 1);
|
||||
expect(objectifs(m, 1)).toBeUndefined();
|
||||
expect(champObjectif(m, '1').value).toBe('');
|
||||
expect(choixRegle(m, '1').value).toBe('');
|
||||
expect(document.activeElement).toBe(champObjectif(m, '1'));
|
||||
|
||||
await saisir(m, champObjectif(m, '2'), '');
|
||||
const refus = zoneObjectif(m, '2').querySelector('[data-refus="OBJECTIF_SANS_ETIQUETTE"]');
|
||||
expect(refus).not.toBeNull();
|
||||
expect(revision(m)).toBe(r0 + 1);
|
||||
await cliquer(m, commande(refus, 'retirerObjectif'));
|
||||
expect(fenetre()).toBeNull();
|
||||
expect(revision(m)).toBe(r0 + 2);
|
||||
expect(objectifs(m, 2)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('« Objectifs par tour » : replié, le bouton compte ceux qui existent ; déplié au clavier, un champ par tour ; posé au tour 2, une entrée, le bouton en compte deux ; replié à l’espace', async () => {
|
||||
const donnees = chargeObjectifs();
|
||||
donnees.tables[0].objectifs = [{ portee: 'tour', tour: 3, regle: null, etiquettes: ['finance'] }];
|
||||
const m = await preparer(donnees);
|
||||
const bouton = commande(ligne(m, 1), 'basculerObjectifsTours');
|
||||
expect(bouton.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(bouton.textContent.trim()).toBe(t('tables.objectifs.parTour', { nombre: 1 }));
|
||||
expect(commande(ligne(m, 2), 'basculerObjectifsTours').textContent.trim()).toBe(t('tables.objectifs.parTour', { nombre: 0 }));
|
||||
expect(zoneObjectif(m, '1-1')).toBeNull();
|
||||
bouton.focus();
|
||||
await touche('Enter');
|
||||
await attendre(m);
|
||||
expect(bouton.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(document.getElementById(bouton.getAttribute('aria-controls')).contains(zoneObjectif(m, '1-1'))).toBe(true);
|
||||
expect(['1-1', '1-2', '1-3'].map((cle) => zoneObjectif(m, cle).dataset.portee)).toEqual(['tour', 'tour', 'tour']);
|
||||
expect(champObjectif(m, '1-3').value).toBe('finance');
|
||||
expect(zoneObjectif(m, '2-1')).toBeNull();
|
||||
const r0 = revision(m);
|
||||
await saisir(m, champObjectif(m, '1-2'), 'cuisine');
|
||||
expect(revision(m)).toBe(r0 + 1);
|
||||
expect(objectifs(m, 1).map(({ tour }) => tour)).toEqual([2, 3]);
|
||||
expect(bouton.textContent.trim()).toBe(t('tables.objectifs.parTour', { nombre: 2 }));
|
||||
bouton.focus();
|
||||
await touche(' ');
|
||||
await attendre(m);
|
||||
expect(bouton.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(zoneObjectif(m, '1-2')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('« Exporter les objectifs » (§ 10.4)', () => {
|
||||
// Une proposition retenue, puis le plan bloqué.
|
||||
function chargeBloquee() {
|
||||
const donnees = chargeObjectifs();
|
||||
donnees.tables[0].objectifs = [
|
||||
{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] },
|
||||
{ portee: 'tour', tour: 2, regle: 'et', etiquettes: ['cuisine', 'finance'] },
|
||||
];
|
||||
placer(donnees, [[[1, 2], [3, 4]], [[1, 3], [2, 4]], [[1, 4], [2, 3]]], { id: 1, retenu: true });
|
||||
donnees.evenement.etat = 'bloque';
|
||||
return donnees;
|
||||
}
|
||||
|
||||
test('en écriture, en lecture et sur un plan bloqué : les octets d’exporterObjectifs, sous ‹base›-objectifs.csv, rien d’écrit au fichier', async () => {
|
||||
// Un plan bloqué ne passe pas en écriture : la séance le tient en
|
||||
// lecture seule, et refuse tout geste qui modifie.
|
||||
const ouverte = chargeObjectifs();
|
||||
ouverte.tables[1].objectifs = [{ portee: 'tour', tour: 3, regle: null, etiquettes: ['cuisine'] }];
|
||||
for (const [donnees, mode, refus] of [
|
||||
[ouverte, 'ecriture', null],
|
||||
[ouverte, 'lecture', 'MODE_LECTURE'],
|
||||
[chargeBloquee(), 'lecture', 'LECTURE_SEULE'],
|
||||
]) {
|
||||
const m = await preparer(donnees, { mode });
|
||||
expect(m.seance.etat().refus?.code ?? null).toBe(refus);
|
||||
expect(charge(m).tables.some(({ objectifs: liste }) => liste !== undefined)).toBe(true);
|
||||
const avant = ecritures(m);
|
||||
const bouton = commande(section(m), 'exporterObjectifs');
|
||||
expect(bouton.textContent).toBe(t('tables.objectifs.exporter'));
|
||||
await cliquer(m, bouton);
|
||||
await attendre(m);
|
||||
expect(m.enregistres).toHaveLength(1);
|
||||
expect(m.enregistres[0].nomPropose).toBe(`${BASE}-objectifs.csv`);
|
||||
expect([...m.enregistres[0].octets]).toEqual([...exporterObjectifs(charge(m).tables)]);
|
||||
expect(bouton.parentElement.querySelector('[data-refus]')).toBeNull();
|
||||
expect(ecritures(m)).toBe(avant);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('les objectifs que les fenêtres nomment (§ 2.1 étape 5, § 4.3, § 6.2, A3, A6)', () => {
|
||||
test('trois tours, un objectif au tour 3 et un de la soirée ; 2 saisi : la fenêtre nomme l’objectif du tour 3, rien d’écrit ; Confirmer : il part, la soirée reste', async () => {
|
||||
const donnees = chargeObjectifs();
|
||||
donnees.tables[1].objectifs = [
|
||||
{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] },
|
||||
{ portee: 'tour', tour: 3, regle: null, etiquettes: ['cuisine'] },
|
||||
];
|
||||
const m = await preparer(donnees);
|
||||
const avant = ecritures(m);
|
||||
await saisir(m, champ(m, 'tours'), '2');
|
||||
expect(fenetre()).not.toBeNull();
|
||||
expect(ecritures(m)).toBe(avant);
|
||||
expect(elementsFenetre()).toEqual([t('tables.tours.objectif', { numero: 2, tour: 3, etiquettes: ['cuisine'] })]);
|
||||
await cliquer(m, commande(fenetre(), 'confirmer'));
|
||||
expect(fenetre()).toBeNull();
|
||||
expect(objectifs(m, 2)).toEqual([{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }]);
|
||||
});
|
||||
|
||||
test('une table qui ne porte qu’un objectif : sa suppression ouvre la fenêtre, qui le nomme', async () => {
|
||||
const donnees = chargeObjectifs();
|
||||
donnees.tables[0].objectifs = [{ portee: 'tour', tour: 2, regle: 'et', etiquettes: ['cuisine', 'finance'] }];
|
||||
const m = await preparer(donnees);
|
||||
await cliquer(m, commande(ligne(m, 1), 'supprimerTable'));
|
||||
expect(fenetre()).not.toBeNull();
|
||||
expect(elementsFenetre()).toEqual([t('tables.suppression.objectif', { tour: 2, etiquettes: ['cuisine', 'finance'] })]);
|
||||
await cliquer(m, commande(fenetre(), 'renoncer'));
|
||||
expect(charge(m).tables).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('les objectifs en lecture (§ 8.4)', () => {
|
||||
test('poser un objectif, choisir la règle par défaut ou celle d’un objectif, le retirer : refusés contre le geste, le message porte « Modifier », rien d’écrit ; le choix montre la règle enregistrée', async () => {
|
||||
const donnees = chargeObjectifs();
|
||||
donnees.tables[0].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }];
|
||||
const m = await preparer(donnees, { mode: 'lecture' });
|
||||
const avant = ecritures(m);
|
||||
const modifier = t('communs.remede.modifier');
|
||||
|
||||
await cliquer(m, champObjectif(m, '2'));
|
||||
await touche('x');
|
||||
await attendre(m);
|
||||
expect(zoneObjectif(m, '2').querySelector('[data-refus="MODE_LECTURE"]').textContent).toContain(modifier);
|
||||
|
||||
await choisirAuClavier(m, regleDefaut(m), 'et');
|
||||
expect(regleDefaut(m).value).toBe('ou');
|
||||
expect(regleDefaut(m).closest('[data-regle-objectifs]').querySelector('[data-refus="MODE_LECTURE"]').textContent).toContain(modifier);
|
||||
|
||||
const decrit = (choix) => document.getElementById(choix.getAttribute('aria-describedby'));
|
||||
expect(decrit(regleDefaut(m)).dataset.refus).toBe('MODE_LECTURE');
|
||||
|
||||
await choisirAuClavier(m, choixRegle(m, '1'), 'ou');
|
||||
expect(choixRegle(m, '1').value).toBe('');
|
||||
expect(zoneObjectif(m, '1').querySelector('[data-refus="MODE_LECTURE"]').textContent).toContain(modifier);
|
||||
expect(choixRegle(m, '1').getAttribute('aria-invalid')).toBe('true');
|
||||
expect(decrit(choixRegle(m, '1')).dataset.refus).toBe('MODE_LECTURE');
|
||||
expect(decrit(choixRegle(m, '1')).textContent).toContain(modifier);
|
||||
expect(document.activeElement).toBe(choixRegle(m, '1'));
|
||||
|
||||
// Le choix d'un objectif que la table ne porte pas encore : refusé de
|
||||
// même, il rend la valeur vide.
|
||||
await choisirAuClavier(m, choixRegle(m, '2'), 'et');
|
||||
expect(choixRegle(m, '2').value).toBe('');
|
||||
expect(decrit(choixRegle(m, '2')).dataset.refus).toBe('MODE_LECTURE');
|
||||
|
||||
await cliquer(m, commande(zoneObjectif(m, '1'), 'retirerObjectif'));
|
||||
expect(commande(zoneObjectif(m, '1'), 'retirerObjectif').parentElement.querySelector('[data-refus="MODE_LECTURE"]')).not.toBeNull();
|
||||
expect(objectifs(m, 1)).toHaveLength(1);
|
||||
expect(charge(m).reglages.regleObjectifs).toBeUndefined();
|
||||
expect(ecritures(m)).toBe(avant);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,9 +8,11 @@
|
|||
// aussitôt quand rien ne se perd — une hausse, dont la dérive des
|
||||
// placements s'avertit par la séance ; sinon la fenêtre à confirmer
|
||||
// nomme, avant toute écriture, les réservations « tour désigné » qu'elle
|
||||
// détruit — personne et table — et les placements qui perdent des tours
|
||||
// (bilanTours, annonceTours). Annuler la ferme et remonte le champ, qui rend la valeur
|
||||
// enregistrée ; Confirmer exécute le changement, une entrée.
|
||||
// détruit — personne et table —, les objectifs de table des tours retirés
|
||||
// — table, tour et étiquettes (§ 4.3, A3) — et les placements qui perdent
|
||||
// des tours (bilanTours, annonceTours, elementsTours). Annuler la ferme et
|
||||
// remonte le champ, qui rend la valeur enregistrée ; Confirmer exécute le
|
||||
// changement, une entrée.
|
||||
import { getContext } from 'svelte';
|
||||
import { bilanTours } from '../../application/bilans.js';
|
||||
import { lireEntier } from '../../application/capacite.js';
|
||||
|
|
|
|||
|
|
@ -156,11 +156,12 @@ export function surchargesPerdues(charge) {
|
|||
|
||||
/**
|
||||
* Vrai quand le changement de avant à apres tours s'annonce avant d'être
|
||||
* appliqué (§ 2.1 étape 5) : il détruit une réservation « tour désigné »
|
||||
* au-delà, ou, pour une baisse, il laisse en dérive un placement engendré,
|
||||
* entier mais qui porte des tours que l'événement n'a plus. Une hausse ne
|
||||
* détruit rien : les placements qu'elle laisse en dérive restent entiers,
|
||||
* et la séance en avertit (DERIVE_NOUVELLE) sans fenêtre (§ 5.9).
|
||||
* appliqué (§ 2.1 étape 5, § 4.3) : il détruit une réservation « tour
|
||||
* désigné » ou un objectif de table d'un tour au-delà, ou, pour une baisse,
|
||||
* il laisse en dérive un placement engendré, entier mais qui porte des
|
||||
* tours que l'événement n'a plus. Une hausse ne détruit rien : les
|
||||
* placements qu'elle laisse en dérive restent entiers, et la séance en
|
||||
* avertit (DERIVE_NOUVELLE) sans fenêtre (§ 5.9).
|
||||
*
|
||||
* @param {ReturnType<import('../../application/bilans.js').bilanTours>} bilan
|
||||
* @param {number} avant le nombre de tours enregistré
|
||||
|
|
@ -169,6 +170,7 @@ export function surchargesPerdues(charge) {
|
|||
*/
|
||||
export const annonceTours = (bilan, avant, apres) =>
|
||||
bilan.reservationsDetruites.length > 0 ||
|
||||
bilan.objectifsDetruits.length > 0 ||
|
||||
(apres < avant && (bilan.propositionsTouchees.length > 0 || bilan.retenuTouche));
|
||||
|
||||
// Les placements qu'un changement laisse en dérive : chaque proposition,
|
||||
|
|
@ -178,14 +180,26 @@ const touches = (bilan) => [
|
|||
...(bilan.retenuTouche ? [{ cle: 'tables.touchee.retenu', details: {} }] : []),
|
||||
];
|
||||
|
||||
// Les étiquettes de l'objectif de la table id au tour donné : le bilan des
|
||||
// tours ne nomme que la table et le tour d'un objectif détruit.
|
||||
function etiquettesDe(charge, id, tour) {
|
||||
const table = charge.tables.find((candidate) => candidate.id === id);
|
||||
const objectif = table?.objectifs?.find((candidat) => candidat.tour === tour);
|
||||
if (objectif === undefined) throw new TypeError(`objectif inconnu : table ${JSON.stringify(id)}, tour ${JSON.stringify(tour)}`);
|
||||
return objectif.etiquettes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce que nomme la fenêtre d'un changement de tours (§ 2.1 étape 5) : chaque
|
||||
* réservation détruite, sa personne et le numéro de sa table, dans l'ordre
|
||||
* du bilan, puis les placements touchés.
|
||||
* Ce que nomme la fenêtre d'un changement de tours (§ 2.1 étape 5, A3) :
|
||||
* chaque réservation détruite, sa personne et le numéro de sa table, puis
|
||||
* chaque objectif de table détruit, le numéro de sa table, son tour et ses
|
||||
* étiquettes, chacun dans l'ordre du bilan ; puis les placements touchés.
|
||||
* Les objectifs de la soirée restent et ne se nomment pas.
|
||||
*
|
||||
* @param {Object} charge
|
||||
* @param {ReturnType<import('../../application/bilans.js').bilanTours>} bilan
|
||||
* @returns {Array<{cle: string, details: Object}>}
|
||||
* @throws {TypeError} un objectif du bilan que la charge ne porte pas
|
||||
*/
|
||||
export function elementsTours(charge, bilan) {
|
||||
return [
|
||||
|
|
@ -193,18 +207,23 @@ export function elementsTours(charge, bilan) {
|
|||
cle: 'tables.tours.reservation',
|
||||
details: { nom: nomDe(charge, participant), numero: numeroDe(charge.tables, table), tour },
|
||||
})),
|
||||
...bilan.objectifsDetruits.map(({ table, tour }) => ({
|
||||
cle: 'tables.tours.objectif',
|
||||
details: { numero: numeroDe(charge.tables, table), tour, etiquettes: etiquettesDe(charge, table, tour) },
|
||||
})),
|
||||
...touches(bilan),
|
||||
];
|
||||
}
|
||||
|
||||
/** Vrai quand supprimer la table détruit une réservation ou un titre. */
|
||||
export const annonceSuppression = (bilan) => bilan.reservations.length > 0 || bilan.titres.length > 0;
|
||||
/** Vrai quand supprimer la table détruit une réservation, un titre ou un objectif. */
|
||||
export const annonceSuppression = (bilan) => bilan.reservations.length > 0 || bilan.titres.length > 0 || bilan.objectifs.length > 0;
|
||||
|
||||
/**
|
||||
* Ce que nomme la fenêtre de la suppression d'une table (§ 6.2) : ses
|
||||
* Ce que nomme la fenêtre de la suppression d'une table (§ 6.2, A16) : ses
|
||||
* réservations, leur personne, leur portée et leur siège, puis ses titres,
|
||||
* dans l'ordre du bilan ; puis les placements touchés. Rien pour une table
|
||||
* nue, que la suppression ne demande pas de confirmer.
|
||||
* puis ses objectifs, leur tour — null pour la soirée — et leurs
|
||||
* étiquettes, dans l'ordre du bilan ; puis les placements touchés. Rien
|
||||
* pour une table nue, que la suppression ne demande pas de confirmer.
|
||||
*
|
||||
* @param {Object} charge
|
||||
* @param {ReturnType<import('../../application/bilans.js').bilanSuppressionTable>} bilan
|
||||
|
|
@ -218,6 +237,7 @@ export function elementsSuppression(charge, bilan) {
|
|||
details: { nom: nomDe(charge, participant), portee, tour, siege },
|
||||
})),
|
||||
...bilan.titres.map(({ siege, libelle }) => ({ cle: 'tables.suppression.titre', details: { siege, libelle } })),
|
||||
...bilan.objectifs.map(({ tour, etiquettes }) => ({ cle: 'tables.suppression.objectif', details: { tour, etiquettes } })),
|
||||
...touches(bilan),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -277,6 +277,52 @@ describe('ce que nomment les fenêtres (§ 6.1, § 6.2, § 2.1 étape 5)', () =>
|
|||
});
|
||||
});
|
||||
|
||||
// Un objectif de table (§ 4.5) : la soirée pour tour null, sinon le tour
|
||||
// désigné ; regle null suit le défaut.
|
||||
const objectif = (tour, etiquettes, regle = null) => ({ portee: tour === null ? 'tous' : 'tour', tour, regle, etiquettes });
|
||||
|
||||
describe('les objectifs perdus, nommés par les fenêtres (§ 2.1 étape 5, § 4.3, § 6.2, A3, A6, A16)', () => {
|
||||
test('elementsTours : les réservations détruites, puis chaque objectif des tours retirés, sa table et ses étiquettes, puis les placements ; l’objectif de la soirée reste', () => {
|
||||
const charge = chargeDe({ tables: [{ id: 1, numero: 1 }, { id: 2, numero: 5 }], n: 4, tours: 4, reservations: [resa(1, 2, null, 4)] });
|
||||
charge.tables[0].objectifs = [objectif(null, ['finance']), objectif(3, ['Montréal', 'finance'], 'et'), objectif(4, ['bénévole'])];
|
||||
charge.tables[1].objectifs = [objectif(4, ['cuisine'])];
|
||||
charge.propositions = [{ id: 1, tours: 4 }];
|
||||
const bilan = bilanTours(charge, 2);
|
||||
assert.equal(annonceTours(bilan, 4, 2), true);
|
||||
assert.deepEqual(
|
||||
elementsTours(charge, bilan).map(({ cle, details }) => t(cle, details)),
|
||||
[
|
||||
'Iris Ombrelle : réservation à la table 5 au tour 4, détruite',
|
||||
'Table 1 : objectif du tour 3 (« Montréal », « finance »), détruit',
|
||||
'Table 1 : objectif du tour 4 (« bénévole »), détruit',
|
||||
'Table 5 : objectif du tour 4 (« cuisine »), détruit',
|
||||
t('tables.touchee.proposition', { id: 1 }),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('annonceTours : un objectif de tour détruit s’annonce seul, sans réservation ni placement ; celui de la soirée, jamais', () => {
|
||||
const charge = chargeDe({ tables: [{ id: 1 }], n: 2, tours: 3 });
|
||||
charge.tables[0].objectifs = [objectif(null, ['finance'])];
|
||||
assert.equal(annonceTours(bilanTours(charge, 1), 3, 1), false);
|
||||
charge.tables[0].objectifs.push(objectif(3, ['finance']));
|
||||
assert.equal(annonceTours(bilanTours(charge, 2), 3, 2), true);
|
||||
assert.equal(annonceTours(bilanTours(charge, 3), 3, 3), false);
|
||||
});
|
||||
|
||||
test('elementsSuppression : une table qui ne porte que des objectifs s’annonce, chacun nommé, la soirée d’abord, ses étiquettes séparées d’une virgule, sans « et » qui dirait une règle', () => {
|
||||
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 2, tours: 3 });
|
||||
charge.tables[0].objectifs = [objectif(null, ['finance', 'ressources humaines']), objectif(2, ['bénévole'], 'et')];
|
||||
const bilan = bilanSuppressionTable(charge, 1);
|
||||
assert.equal(annonceSuppression(bilan), true);
|
||||
assert.deepEqual(
|
||||
elementsSuppression(charge, bilan).map(({ cle, details }) => t(cle, details)),
|
||||
['Objectif de la soirée : « finance », « ressources humaines »', 'Objectif du tour 2 : « bénévole »'],
|
||||
);
|
||||
assert.equal(annonceSuppression(bilanSuppressionTable(charge, 2)), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('les compléments d’un refus et d’un avertissement (§ 6.2)', () => {
|
||||
test('siegesRetiresNommes : le siège 8 et son titre, puis le siège 7 et Iris Ombrelle, dans cet ordre', () => {
|
||||
const charge = chargeDe({
|
||||
|
|
|
|||
157
src/interface/tables/objectifs.js
Normal 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;
|
||||
}
|
||||
194
src/interface/tables/objectifs.test.js
Normal 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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -54,7 +54,7 @@ const ENTREES = [
|
|||
['data-conversion', 'plan : une conversion offerte par une liste de travail', null],
|
||||
['data-corbeille', 'accueil : la corbeille', ['']],
|
||||
['data-corbeille-dossier', 'accueil : un dossier de la corbeille', null],
|
||||
['data-critere', 'propositions : un nom de critère de la phrase de l’ordre', null],
|
||||
['data-critere', 'propositions : un nom de la phrase de l’ordre, par le critère qui classe sa valeur, objectifs et animateurs compris', null],
|
||||
['data-defaut', 'tables : le réglage du défaut de places', ['']],
|
||||
['data-demonstration', 'accueil : une démonstration, par sa clé', null],
|
||||
['data-derive', 'événement : un placement en dérive, par son identifiant', null],
|
||||
|
|
@ -75,6 +75,7 @@ const ENTREES = [
|
|||
['data-etat', 'événement : un état du plan dans la tête', null],
|
||||
['data-etat-table', 'tables : l’état d’une table', ['']],
|
||||
['data-etendue', 'propositions : l’étendue d’une page du classement', ['']],
|
||||
['data-etiquettes', 'participants : les étiquettes de la fiche, leurs pastilles et leur saisie', ['']],
|
||||
['data-evenement', 'accueil : une ligne de la liste, par sa base', null],
|
||||
['data-evidence', 'plan : la personne mise en évidence', null],
|
||||
['data-fantome', 'plan : le fantôme d’une personne glissée', ['']],
|
||||
|
|
@ -86,6 +87,7 @@ const ENTREES = [
|
|||
['data-focus-initial', 'plan : le siège qui reçoit le focus à l’ouverture du panneau', null],
|
||||
['data-fusion', 'import : un groupe d’orthographes fondu', ['']],
|
||||
['data-fusions', 'import : les groupes d’orthographes', ['']],
|
||||
['data-fusions-etiquettes', 'import : les étiquettes fondues, à part des appartenances', ['']],
|
||||
['data-groupe', 'participants : l’en-tête d’un groupe, par sa clé', null],
|
||||
['data-groupe-compte', 'participants : le compte d’un groupe', ['']],
|
||||
['data-groupe-conflits', 'participants : la marque de conflit d’un groupe', ['']],
|
||||
|
|
@ -98,6 +100,7 @@ const ENTREES = [
|
|||
['data-hors-classement', 'propositions : les lignes hors classement', ['']],
|
||||
['data-issue', 'génération : l’issue de la dernière génération', ['']],
|
||||
['data-legende', 'plan : la légende des conflits', ['']],
|
||||
['data-legende-etiquettes', 'participants : la légende des étiquettes', ['']],
|
||||
['data-legende-pastille', 'plan : la pastille de la légende', ['']],
|
||||
['data-legende-regle', 'plan : une entrée de la légende, par sa règle', null],
|
||||
['data-ligne', 'import : une ligne de l’aperçu, par son rang', null],
|
||||
|
|
@ -116,14 +119,18 @@ const ENTREES = [
|
|||
['data-menu', 'plan : le menu d’une personne', null],
|
||||
['data-mode', 'événement : la barre, la tête et la surface de l’événement, par son mode', null],
|
||||
['data-mode-libelle', 'le libellé de mode', ['bandeau', 'barre']],
|
||||
['data-mode-objectifs', 'génération : le groupe du choix du mode des objectifs', ['']],
|
||||
['data-motif', 'import : le motif d’une ligne refusée', ['']],
|
||||
['data-multiligne', 'import : une ligne sur plusieurs lignes de texte', null],
|
||||
['data-nature-import', 'import : le choix de la nature du fichier', ['']],
|
||||
['data-niveau', 'participants : le niveau d’un groupe ou d’un sélecteur', null],
|
||||
['data-nom-imprime', 'aperçu : le nom entier d’une personne sur une liste imprimée', ['']],
|
||||
['data-nombre', 'import : un nombre affiché', ['']],
|
||||
['data-objectif', 'tables : l’objectif d’une table, par sa table, et son tour pour un tour désigné', null],
|
||||
['data-ordre', 'propositions : la phrase de l’ordre', ['']],
|
||||
['data-orthographe', 'import : une orthographe d’un groupe', ['']],
|
||||
['data-pages', 'propositions : une nav des pages du classement, haut ou bas', null],
|
||||
['data-palier', 'génération : un palier de l’éditeur des priorités, par son index', null],
|
||||
['data-panneau', 'plan : un panneau', ['place', 'table']],
|
||||
['data-panneau-diagnostic', 'plan : le panneau du diagnostic', ['']],
|
||||
['data-papier', 'le papier de la machine : la question, ou le papier choisi', ['choisi', 'question']],
|
||||
|
|
@ -135,7 +142,11 @@ const ENTREES = [
|
|||
['data-place-fixe', 'événement : la place fixe de la barre de l’événement', ['']],
|
||||
['data-place-vide', 'aperçu : une place vide de la feuille par table', ['']],
|
||||
['data-plan', 'plan : le <svg> du plan', ['']],
|
||||
['data-plancher-objectifs', 'diagnostic : la phrase d’un plancher des objectifs, par la valeur que le mode compare', null],
|
||||
['data-poignee', 'plan : la poignée d’une table', null],
|
||||
['data-portee', 'tables : la portée d’un objectif, tous pour la soirée, tour pour un tour désigné', ['tous', 'tour']],
|
||||
['data-priorite', 'génération : un critère de l’éditeur des priorités, par son nom', null],
|
||||
['data-priorites', 'génération : la liste des paliers de l’éditeur des priorités', ['']],
|
||||
['data-proposition', 'propositions : une ligne, par son identifiant', null],
|
||||
['data-question', 'une question posée : dossier de travail ou durée longue', null],
|
||||
['data-rapport', 'import : le rapport', ['']],
|
||||
|
|
@ -143,6 +154,7 @@ const ENTREES = [
|
|||
['data-refus-realigner', 'tables : le refus du réalignement', ['']],
|
||||
['data-refusees', 'import : les lignes refusées', ['']],
|
||||
['data-reglages', 'génération : les réglages', ['']],
|
||||
['data-regle-objectifs', 'tables : la règle par défaut des objectifs', ['']],
|
||||
['data-remede', 'diagnostic : un remède', ['']],
|
||||
['data-reserve', 'plan : la réserve d’un tour', null],
|
||||
['data-retenu', 'propositions : la ligne du retenu', null],
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { appliquerCommande } from '../../src/application/commandes.js';
|
|||
import { ErreurAnnulee } from '../../src/application/generation.js';
|
||||
import { texteLivre } from '../../src/demo/livrees.js';
|
||||
import { SECTIONS } from '../../src/interface/evenement/sections.js';
|
||||
import { LISTE_PAR_DEFAUT, listeApres } from '../../src/interface/generation/priorites.js';
|
||||
import { rechercher } from '../../src/moteur/recherche.js';
|
||||
import { serialiser } from '../../src/stockage/canonique.js';
|
||||
import { analyser, capacite, configurationDepuisCharge } from '../../src/stockage/document.js';
|
||||
|
|
@ -652,6 +653,29 @@ export const ECRANS = Object.freeze([
|
|||
return m.racine;
|
||||
},
|
||||
},
|
||||
{
|
||||
nom: 'participants.etiquettes',
|
||||
mode: 'ecriture',
|
||||
async atteindre(contexte) {
|
||||
// Deux personnes étiquetées, puis la fiche d'une troisième, où
|
||||
// « FINANCE » tapé au clavier s'enregistre sous l'orthographe que
|
||||
// l'événement porte : les pastilles neutres et leur « Retirer », le
|
||||
// signal d'orthographe contre le champ, et la légende des étiquettes
|
||||
// sous celle des appartenances.
|
||||
let charge = chargePrincipale();
|
||||
charge = executer(charge, 'changerEtiquettes', { id: 1, ajouter: 'finance, Montréal' });
|
||||
charge = executer(charge, 'changerEtiquettes', { id: 2, ajouter: 'bénévole' });
|
||||
const m = await ouvrirEvenement(contexte, charge, { ecriture: true, section: 'participants' });
|
||||
await cliquer(m, '[data-participant="3"] [data-commande="fiche"]');
|
||||
const champ = await element(m, '[data-fiche="3"] [data-champ="etiquettes"]');
|
||||
champ.focus();
|
||||
for (const caractere of 'FINANCE') await touche(caractere);
|
||||
await touche('Enter');
|
||||
await element(m, '[data-fiche="3"] [data-etiquettes] [data-avertissement="ORTHOGRAPHE_RETENUE"]');
|
||||
await element(m, '[data-legende-etiquettes]');
|
||||
return m.racine;
|
||||
},
|
||||
},
|
||||
{
|
||||
nom: 'participants.groupes',
|
||||
mode: 'ecriture',
|
||||
|
|
@ -732,6 +756,27 @@ export const ECRANS = Object.freeze([
|
|||
return m.racine;
|
||||
},
|
||||
},
|
||||
{
|
||||
nom: 'tables.objectifs',
|
||||
mode: 'ecriture',
|
||||
async atteindre(contexte) {
|
||||
// La règle par défaut dans la carte des réglages ; la table 1 vise
|
||||
// « finance » pour la soirée sous sa règle propre, et au tour 2 en
|
||||
// suivant le défaut, ses objectifs par tour dépliés ; la table 2 vise
|
||||
// « cuisine », que personne ne porte : ses avis contre l'objectif.
|
||||
let charge = chargePrincipale();
|
||||
charge = executer(charge, 'changerEtiquettes', { id: 1, ajouter: 'finance' });
|
||||
charge = executer(charge, 'poserObjectif', { table: 1, tour: null, etiquettes: 'finance', regle: 'ou' });
|
||||
charge = executer(charge, 'poserObjectif', { table: 1, tour: 2, etiquettes: 'finance', regle: null });
|
||||
charge = executer(charge, 'poserObjectif', { table: 2, tour: null, etiquettes: 'cuisine', regle: null });
|
||||
const m = await ouvrirEvenement(contexte, charge, { ecriture: true, section: 'tables' });
|
||||
await element(m, '[data-regle-objectifs]');
|
||||
await cliquer(m, '[data-section="tables"] [data-table="1"] [data-commande="basculerObjectifsTours"]');
|
||||
await element(m, '[data-objectif="1-2"][data-portee="tour"]');
|
||||
await element(m, '[data-objectif="2"] [data-avertissement="ETIQUETTE_ORPHELINE"]');
|
||||
return m.racine;
|
||||
},
|
||||
},
|
||||
{
|
||||
nom: 'evenement.avertissements',
|
||||
mode: 'lecture',
|
||||
|
|
@ -759,6 +804,38 @@ export const ECRANS = Object.freeze([
|
|||
return m.racine;
|
||||
},
|
||||
},
|
||||
{
|
||||
nom: 'generation.priorites',
|
||||
mode: 'ecriture',
|
||||
async atteindre(contexte) {
|
||||
// L'éditeur des priorités sur un ordre réglé — l'excédent et les
|
||||
// collisions cumulées au même palier, chacun son poids —, puis la
|
||||
// redondance jointe aux animateurs : l'annonce du geste paraît.
|
||||
const paliers = listeApres(LISTE_PAR_DEFAUT, 'joindrePalier', 'collisionsCumulees');
|
||||
const charge = executer(chargePrincipale(), 'changerPriorites', { paliers });
|
||||
const m = await ouvrirEvenement(contexte, charge, { ecriture: true, section: 'generation' });
|
||||
await cliquer(m, '[data-section="generation"] [data-priorite="redondance"] [data-commande="joindrePalier"]');
|
||||
await attendre("l'annonce du geste", () => {
|
||||
const annonce = m.racine.querySelector('[data-section="generation"] [data-priorites] ~ [role="status"]');
|
||||
return (annonce?.textContent.trim() ?? '') !== '';
|
||||
});
|
||||
return m.racine;
|
||||
},
|
||||
},
|
||||
{
|
||||
nom: 'generation.modeObjectifs',
|
||||
mode: 'ecriture',
|
||||
async atteindre(contexte) {
|
||||
// Les tours hors objectif partagent un palier : choisir « l'équité
|
||||
// d'abord » se refuse contre le groupe du mode, le choix revenu.
|
||||
const paliers = listeApres(LISTE_PAR_DEFAUT, 'joindrePalier', 'toursHorsObjectif');
|
||||
const charge = executer(chargePrincipale(), 'changerPriorites', { paliers });
|
||||
const m = await ouvrirEvenement(contexte, charge, { ecriture: true, section: 'generation' });
|
||||
await cliquer(m, '[data-section="generation"] [data-mode-objectifs] input[value="minimum"]');
|
||||
await element(m, '[data-mode-objectifs] [data-refus="OBJECTIFS_PALIER_PARTAGE"]');
|
||||
return m.racine;
|
||||
},
|
||||
},
|
||||
{
|
||||
nom: 'propositions.critereSurvole',
|
||||
mode: 'lecture',
|
||||
|
|
|
|||
|
|
@ -357,6 +357,12 @@ const PICTOGRAMME = /\p{Extended_Pictographic}/u;
|
|||
// remèdes disent qu'un changement « la rend » ou « la rendent évitable ».
|
||||
// Ailleurs, « évitable » nommerait le second segment du manque (§ 12.4), que
|
||||
// le logiciel n'écrit pas.
|
||||
//
|
||||
// Le troisième mode des objectifs garde le libellé que l'utilisateur lui a
|
||||
// donné, « l'équité d'abord » (D22) : il nomme le minimum d'abord (§ 12.1),
|
||||
// jamais un indice d'équité (§ 12.8). La forme exacte s'admet sous la seule
|
||||
// clé de ce libellé ; son explication, et tout autre texte, ne l'emploient
|
||||
// pas.
|
||||
const EXCEPTIONS_DE_CONTENU = [
|
||||
{
|
||||
mot: 'évitable',
|
||||
|
|
@ -364,8 +370,17 @@ const EXCEPTIONS_DE_CONTENU = [
|
|||
forme: /\bla rend(?:ent)? evitable\b/g,
|
||||
nom: 'diagnostic.remede.* « la rend(ent) évitable »',
|
||||
},
|
||||
{
|
||||
mot: 'équité',
|
||||
cles: /^generation\.reglage\.modeObjectifs\.minimum$/,
|
||||
forme: /(?<![\p{L}\p{N}])l'equite d'abord(?![\p{L}\p{N}])/gu,
|
||||
nom: "generation.reglage.modeObjectifs.minimum « l'équité d'abord » (D22)",
|
||||
},
|
||||
];
|
||||
|
||||
// Les exceptions d'un seul mot, pour une épreuve qui n'éprouve que lui.
|
||||
const exceptionsDu = (mot) => EXCEPTIONS_DE_CONTENU.filter((exception) => exception.mot === mot);
|
||||
|
||||
// Une chaîne de forme clé — un code, LECTURE_SEULE, ou un libellé,
|
||||
// plan.refus.fermer — : une fonction la passe à texte, ou la compare à un
|
||||
// détail, et ne l'affiche pas.
|
||||
|
|
@ -1326,14 +1341,107 @@ describe('sources de l’interface : le contenu des tables de traduction (§ 5.5
|
|||
['plan.segment', 'la rend évitable'],
|
||||
['diagnostic.remede.inevitable', 'inévitable'],
|
||||
]);
|
||||
assert.deepEqual(releverContenuTables([tables]), [
|
||||
assert.deepEqual(releverContenuTables([tables], () => null, exceptionsDu('évitable')), [
|
||||
'diagnostic.remede.autre : évitable « la place devient évitable »',
|
||||
'diagnostic.remede.reste : évitable « la rend évitable, part évitable »',
|
||||
'diagnostic.titre : évitable « un tabouret la rend évitable »',
|
||||
'plan.segment : évitable « la rend évitable »',
|
||||
]);
|
||||
const sansRemede = new Map([['diagnostic.remede.un', 'ajouter une table']]);
|
||||
assert.deepEqual(releverContenuTables([sansRemede]), ['exception sans objet : diagnostic.remede.* « la rend(ent) évitable »']);
|
||||
assert.deepEqual(releverContenuTables([sansRemede], () => null, exceptionsDu('évitable')), [
|
||||
'exception sans objet : diagnostic.remede.* « la rend(ent) évitable »',
|
||||
]);
|
||||
});
|
||||
|
||||
test('rouge : l’exception D22 n’admet « l’équité d’abord » que sous la clé du libellé du troisième mode ; tout autre emploi d’« équité » se relève, l’explication du mode comprise', () => {
|
||||
const tables = new Map([
|
||||
['generation.reglage.modeObjectifs.minimum', 'l\u{2019}équité d\u{2019}abord'],
|
||||
['generation.reglage.modeObjectifs.minimum.explication', "l'équité d'abord, puis le total"],
|
||||
['generation.reglage.modeObjectifs.chacun', "L'ÉQUITÉ D'ABORD"],
|
||||
['propositions.phrase', "l'équité"],
|
||||
['diagnostic.remede.un', '2 chaises de plus la rend évitable'],
|
||||
]);
|
||||
assert.deepEqual(releverContenuTables([tables]), [
|
||||
"generation.reglage.modeObjectifs.chacun : équité « L'ÉQUITÉ D'ABORD »",
|
||||
"generation.reglage.modeObjectifs.minimum.explication : équité « l'équité d'abord, puis le total »",
|
||||
"propositions.phrase : équité « l'équité »",
|
||||
]);
|
||||
// Sous sa clé, une autre forme du mot se relève encore.
|
||||
const autre = new Map([
|
||||
['generation.reglage.modeObjectifs.minimum', "l'équité d'abord, une équité"],
|
||||
['diagnostic.remede.un', 'la rend évitable'],
|
||||
]);
|
||||
assert.deepEqual(releverContenuTables([autre]), [
|
||||
"generation.reglage.modeObjectifs.minimum : équité « l'équité d'abord, une équité »",
|
||||
]);
|
||||
const sansMode = new Map([['diagnostic.remede.un', 'la rend évitable']]);
|
||||
assert.deepEqual(releverContenuTables([sansMode]), [
|
||||
"exception sans objet : generation.reglage.modeObjectifs.minimum « l'équité d'abord » (D22)",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Garde : l'état des critères se lit, il ne se recalcule pas ---------------
|
||||
|
||||
// Les sources qui écrivent l'état « décoché » ou « sans objet » d'un critère
|
||||
// (§ 5.3, A8) : l'éditeur des priorités, les réglages qui portent le choix du
|
||||
// mode, et leur modèle.
|
||||
const SOURCES_DE_L_ETAT = [
|
||||
'src/interface/generation/Priorites.svelte',
|
||||
'src/interface/generation/Reglages.svelte',
|
||||
'src/interface/generation/priorites.js',
|
||||
];
|
||||
|
||||
// Ce que la garde relève d'un « sans objet » recalculé : une variable ou une
|
||||
// clé sansObjet qui reçoit une valeur, et chaque lecture des objets de la
|
||||
// charge d'où il se déduirait — tables, participants, titres, réservations,
|
||||
// objectifs, étiquettes, animations. Ces sources lisent l'état dans la table
|
||||
// d'évaluation, qui le tient du moteur (§ 13.2) : aucune n'en a l'emploi. Une
|
||||
// lecture est un accès pointé, un accès entre crochets par une chaîne, ou
|
||||
// une déstructuration qui nomme l'objet — un motif entre accolades suivi de
|
||||
// « = », hors « == » et « => », ou de la parenthèse d'un paramètre. Les
|
||||
// commentaires ne calculent rien et se retirent d'abord ; un commentaire de
|
||||
// ligne commence en début de ligne ou après un blanc.
|
||||
const OBJETS_DE_LA_CHARGE = '(?:tables|participants|titres|reservations|objectifs|etiquettes|anime)';
|
||||
function relevesSansObjet(texte) {
|
||||
const source = texte
|
||||
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
.replace(/<!--[\s\S]*?-->/g, ' ')
|
||||
.replace(/(^|\s)\/\/.*$/gm, ' ');
|
||||
const motifs = [
|
||||
/\bsansObjet\s*(?::|=(?!=))/g,
|
||||
new RegExp(`\\.${OBJETS_DE_LA_CHARGE}\\b`, 'g'),
|
||||
new RegExp(`\\{[^{}]*\\b${OBJETS_DE_LA_CHARGE}\\b[^{}]*\\}\\s*(?:=(?![=>])|\\))`, 'g'),
|
||||
new RegExp(`\\[\\s*(['"\`])${OBJETS_DE_LA_CHARGE}\\1\\s*\\]`, 'g'),
|
||||
];
|
||||
return motifs.flatMap((motif) => [...source.matchAll(motif)].map(([trouve]) => trouve.replace(/\s+/g, ' ')));
|
||||
}
|
||||
|
||||
describe('sources de l’interface : l’état des critères, lu dans la table d’évaluation (§ 5.3, § 13.2, A8)', () => {
|
||||
test('rouge : la garde relève un « sans objet » recalculé, et laisse passer une lecture de l’état', () => {
|
||||
assert.deepEqual(relevesSansObjet('const sansObjet = charge.tables.every((t) => t.objectifs === undefined);'), [
|
||||
'sansObjet =',
|
||||
'.tables',
|
||||
'.objectifs',
|
||||
]);
|
||||
assert.deepEqual(relevesSansObjet('const vide = { coche: true, sansObjet: false };'), ['sansObjet:']);
|
||||
assert.deepEqual(relevesSansObjet('const muet = etat.sansObjet === true; // charge.tables'), []);
|
||||
// Une déstructuration, en déclaration ou en paramètre, et un accès entre
|
||||
// crochets lisent les mêmes objets sans point.
|
||||
const deguise =
|
||||
'const { tables: lesTables } = charge;\n' +
|
||||
"const aucun = $derived(lesTables.every((t) => (t['objectifs'] ?? []).length === 0));\n" +
|
||||
'const compte = ({ participants }) => participants.length;';
|
||||
assert.deepEqual(relevesSansObjet(deguise), ['{ tables: lesTables } =', '{ participants })', "['objectifs']"]);
|
||||
assert.deepEqual(relevesSansObjet("const { table, refus } = evaluation(charge);\nconst etat = etats?.get('tables');"), []);
|
||||
});
|
||||
|
||||
test('l’éditeur des priorités, les réglages et leur modèle ne recalculent pas l’état d’un critère', () => {
|
||||
for (const chemin of SOURCES_DE_L_ETAT) {
|
||||
const source = readFileSync(join(RACINE, chemin), 'utf8');
|
||||
assert.ok(source.includes('etatAffiche'), `${chemin} : l'état ne s'y lit plus, la garde est à revoir`);
|
||||
assert.deepEqual(relevesSansObjet(source), [], chemin);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||