From 7dc23946c286066d02e31df44c547eafd0203346 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 11 Oct 2026 16:19:38 -0400 Subject: [PATCH] [ADD] priorities: search and ranking follow tiers, goals CSV, commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator's priority order now drives both the search and the ranking: each tier compiles into one score component, weights sum inside a shared tier, and the three goal modes compare their own values. With no stored order, no goals and the facilitator case off, plans and rankings equal the reference fingerprints under every mode; incremental and complete scores agree on 1 500 random configurations; a move costs 1 to 2 % more. Table goals import and export through their own CSV, and commands set or restore the order and choose the goal mode. Checked: node 4501, navigateur 885, node-long 124. --- FR --- [ADD] priorités : recherche et classement par paliers, CSV, commandes L'ordre des priorités de l'opérateur mène désormais la recherche et le classement : chaque palier devient une composante du score, les poids s'additionnent dans un palier partagé, et les trois modes des objectifs comparent leurs propres valeurs. Sans ordre écrit, sans objectif et sans case des animateurs, plans et classements égalent les empreintes sous chaque mode ; score incrémental et complet concordent sur 1 500 configurations tirées ; un mouvement coûte 1 à 2 % de plus. Les objectifs des tables s'importent et s'exportent par leur CSV ; des commandes posent ou rétablissent l'ordre et choisissent le mode des objectifs. Vérifié : node 4501, navigateur 885, node-long 124. Assisted-by: Claude Opus 5.5 --- GUIDE-USAGE.md | 18 + exemples/objectifs_cas_limites.csv | 8 + scripts/documentation.js | 11 +- scripts/documentation.test.js | 13 +- src/application/commandes.js | 56 +- src/application/commandes.test.js | 107 +++- src/application/commandes_mobilier.js | 200 ++++++- src/application/commandes_mobilier.test.js | 270 ++++++++- src/application/csv.js | 11 +- src/application/csv.test.js | 96 +++- src/application/evaluation.js | 21 +- src/application/evaluation.test.js | 179 +++++- src/application/generation.test.js | 63 +++ src/application/libelles.test.js | 129 ++++- src/application/libelles/csv.js | 60 +- src/application/libelles/mobilier.js | 101 +++- src/application/libelles/moteur.js | 29 +- src/application/registre.test.js | 104 +++- src/csv/colonnes.js | 105 +++- src/csv/colonnes.test.js | 85 ++- src/csv/objectifs.js | 571 +++++++++++++++++++ src/csv/objectifs.test.js | 609 +++++++++++++++++++++ src/interface/libelles/communs.js | 3 + src/interface/libelles/propositions.js | 16 +- src/interface/propositions/modele.js | 53 +- src/interface/propositions/modele.test.js | 89 ++- src/moteur/classement.js | 180 ++++-- src/moteur/classement.test.js | 312 ++++++++++- src/moteur/criteres.js | 31 ++ src/moteur/criteres.test.js | 128 ++++- src/moteur/integration.long.test.js | 56 +- src/moteur/integration.test.js | 6 +- src/moteur/recherche.js | 418 +++++++++++--- src/moteur/recherche.long.test.js | 168 +++++- src/moteur/recherche.test.js | 564 +++++++++++++++++-- test/documentation.test.js | 2 +- test/empreintes_moteur.js | 68 ++- vitest.config.js | 4 +- 38 files changed, 4604 insertions(+), 340 deletions(-) create mode 100644 exemples/objectifs_cas_limites.csv create mode 100644 src/csv/objectifs.js create mode 100644 src/csv/objectifs.test.js diff --git a/GUIDE-USAGE.md b/GUIDE-USAGE.md index 2485698..5c7c4ee 100644 --- a/GUIDE-USAGE.md +++ b/GUIDE-USAGE.md @@ -565,6 +565,21 @@ majuscules ni aux accents. +Les objectifs des tables s’importent par leur propre fichier, qui reconnaît +ces colonnes de la même façon ; la table se désigne par le numéro qu’elle +affiche sur le plan. + + + +| Colonne | Synonymes reconnus | +|---|---| +| `table` | — | +| `tour` | — | +| `objectif` | `objectifs` | +| `regle` | `règle` | + + + ## Annexe C — Les commandes et leur trace dans l’historique Chaque geste qui modifie l’événement, et la ligne qu’il laisse dans @@ -581,6 +596,7 @@ l’historique ; les noms des exemples sont inventés. | `reintegrerParticipant` | Réintégré Théo Pervenche | | `supprimerParticipant` | Supprimé Ondine Lacasse | | `importerParticipants` | Importé 12 participants | +| `importerObjectifs` | Mis à jour les objectifs de table par import : 3 ajoutés, 1 remplacé | | `changerEtat` | Plan passé à l'état « Bloqué » | | `debloquer` | Plan débloqué, état « Retenu » | | `enregistrerGeneration` | Généré 5 propositions (n° 3 à 7) | @@ -598,6 +614,8 @@ l’historique ; les noms des exemples sont inventés. | `poserObjectif` | Table 3 : objectif de la soirée « finance », règle par défaut | | `retirerObjectif` | Table 3, tour 2 : objectif retiré | | `changerRegleObjectifs` | Règle par défaut des objectifs : toutes les étiquettes | +| `changerPriorites` | Ordre des priorités : « collisions cumulées » au palier 2 sur 6, avec « excédent de collisions » | +| `retablirPriorites` | Ordre des priorités : ordre par défaut rétabli | | `reserver` | Réservé Théo Pervenche à la table 4, siège 2, tour 2 | | `retirerReservation` | Réservation retirée : Théo Pervenche, table 4, tous les tours | | `deplacerReservation` | Réservation déplacée : Théo Pervenche, de la table 4 à la table 7, tour 2 | diff --git a/exemples/objectifs_cas_limites.csv b/exemples/objectifs_cas_limites.csv new file mode 100644 index 0000000..724b878 --- /dev/null +++ b/exemples/objectifs_cas_limites.csv @@ -0,0 +1,8 @@ +Table;Tour;Objectif;Règle +1;;finance, bénévole; +1;2;Montréal;toutes +2;;FINANCE;au moins une +9;;finance; +2;9;finance; +2;;ressources humaines;peut-être +1;2;finance; diff --git a/scripts/documentation.js b/scripts/documentation.js index 8e2d80b..5974c0c 100644 --- a/scripts/documentation.js +++ b/scripts/documentation.js @@ -68,6 +68,7 @@ import { creerFormat } from '../src/application/format.js'; import { TABLES_APPLICATION } from '../src/application/libelles.js'; import { creerTraduction } from '../src/application/traduction.js'; import { ENTETES_DES_CHAMPS } from '../src/csv/colonnes.js'; +import { ENTETES_DES_OBJECTIFS } from '../src/csv/objectifs.js'; import { CATALOGUE } from '../src/demo/catalogue.js'; import { LIVREES } from '../src/demo/livrees.js'; import { CONSTANTES } from '../src/geometrie/constantes.js'; @@ -324,8 +325,10 @@ export function tableauReglages(definition = REGLAGES_GENERATION) { } /** - * Le tableau des colonnes du CSV (§ 10.1, § 19.8) : chaque champ, dans - * l'ordre de l'export — son en-tête, puis ses synonymes. + * Le tableau des colonnes du CSV (§ 10.1, § 10.4, § 19.8) : chaque champ, + * dans l'ordre de l'export — son en-tête, puis ses synonymes. La table des + * participants par défaut ; celle des objectifs de table + * (ENTETES_DES_OBJECTIFS) pour la région colonnes-objectifs. * * @param {ReadonlyArray]>} [entetes] * @returns {string} @@ -349,6 +352,7 @@ export const EXEMPLES_COMMANDES = Object.freeze({ reintegrerParticipant: { personne: 'Théo Pervenche' }, supprimerParticipant: { personne: 'Ondine Lacasse' }, importerParticipants: { mode: 'ajouter', ajoutes: 12, misAJour: 0, retires: 0 }, + importerObjectifs: { mode: 'mettreAJour', ajoutes: 3, remplaces: 1, retires: 0 }, changerEtat: { etat: 'bloque' }, debloquer: { etat: 'retenu' }, enregistrerGeneration: { n: 5, premier: 3, dernier: 7 }, @@ -367,6 +371,8 @@ export const EXEMPLES_COMMANDES = Object.freeze({ poserObjectif: { numero: 3, tour: null, regle: null, etiquettes: ['finance'] }, retirerObjectif: { numero: 3, tour: 2 }, changerRegleObjectifs: { regle: 'et' }, + changerPriorites: { criteres: ['collisionsCumulees'], palier: 1, paliers: 6, avec: ['excedentCollisions'], poids: null }, + retablirPriorites: {}, reserver: { personne: 'Théo Pervenche', numero: 4, siege: 2, portee: 'tour', tour: 2 }, retirerReservation: { personne: 'Théo Pervenche', numero: 4, portee: 'tous', tour: null }, deplacerReservation: { personne: 'Théo Pervenche', de: 4, vers: 7, siege: null, portee: 'tour', tour: 2 }, @@ -610,6 +616,7 @@ const producteur = (nom, produire) => Object.freeze({ nom, produire }); export const PRODUCTEURS = Object.freeze([ producteur('reglages', () => tableauReglages()), producteur('colonnes-csv', () => tableauColonnesCsv()), + producteur('colonnes-objectifs', () => tableauColonnesCsv(ENTETES_DES_OBJECTIFS)), producteur('commandes', () => tableauCommandes()), producteur('raccourcis', () => tableauRaccourcis()), producteur('demonstrations', () => tableauDemonstrations()), diff --git a/scripts/documentation.test.js b/scripts/documentation.test.js index 8f5bb27..4d8347e 100644 --- a/scripts/documentation.test.js +++ b/scripts/documentation.test.js @@ -295,10 +295,10 @@ describe('documentation : vérifier les régions (§ 19.8, § 14.3)', () => { }); describe('documentation : les producteurs, chacun sur sa source unique (§ 19.8)', () => { - test('les sept producteurs, dans leur ordre ; PORTEURS est une liste figée', () => { + test('les huit producteurs, dans leur ordre ; PORTEURS est une liste figée', () => { assert.deepEqual( PRODUCTEURS.map(({ nom }) => nom), - ['reglages', 'colonnes-csv', 'commandes', 'raccourcis', 'demonstrations', 'contrastes', 'constantes'], + ['reglages', 'colonnes-csv', 'colonnes-objectifs', 'commandes', 'raccourcis', 'demonstrations', 'contrastes', 'constantes'], ); assert.ok(Object.isFrozen(PRODUCTEURS) && Object.isFrozen(PORTEURS)); assert.deepEqual([...producteursDe({ manifeste: () => null }).keys()], PRODUCTEURS.map(({ nom }) => nom)); @@ -335,6 +335,15 @@ describe('documentation : les producteurs, chacun sur sa source unique (§ 19.8) assert.deepEqual(lignes.find(([c]) => c === '`titre_pressenti`'), ['`titre_pressenti`', '`titre`, `rôle`']); }); + test('colonnes-objectifs : une ligne par champ du CSV des objectifs, son en-tête puis ses synonymes (§ 10.4)', () => { + assert.deepEqual(lignesDuTableau(PRODUCTEURS.find(({ nom }) => nom === 'colonnes-objectifs').produire({})), [ + ['`table`', t('format.vide')], + ['`tour`', t('format.vide')], + ['`objectif`', '`objectifs`'], + ['`regle`', '`règle`'], + ]); + }); + test("commandes : une ligne par commande du registre, dans son ordre, et son libellé d'historique composé par la traduction", () => { const lignes = lignesDuTableau(tableauCommandes()); assert.deepEqual(lignes.map(([nom]) => nom), [...COMMANDES.keys()].map((nom) => `\`${nom}\``)); diff --git a/src/application/commandes.js b/src/application/commandes.js index 3e4e1e1..4198617 100644 --- a/src/application/commandes.js +++ b/src/application/commandes.js @@ -27,6 +27,7 @@ // effacer les propositions ramène un plan proposé sans retenu à brouillon. import { ErreurCsv } from '../csv/erreurs.js'; import { appliquerImport, bilanRemplacement } from '../csv/import.js'; +import { appliquerImportObjectifs } from '../csv/objectifs.js'; import { serialiserCharge } from '../stockage/canonique.js'; import { SCHEMA, clesRangees, creerCharge, etatDeduit, premiereFaute } from '../stockage/document.js'; import { ErreurStockage } from '../stockage/erreurs.js'; @@ -37,7 +38,7 @@ import { COMMANDES_MOBILIER } from './commandes_mobilier.js'; import { COMMANDES_PLACES } from './commandes_places.js'; import { ErreurCommande, refus } from './erreurs.js'; import { libelle } from './libelles.js'; -import { etiquettesDeLEvenement, etiquettesRangees, recevoirEtiquettes } from './objectifs.js'; +import { avisObjectifs, etiquettesDeLEvenement, etiquettesRangees, recevoirEtiquettes } from './objectifs.js'; import { nomAffiche } from './personnes.js'; import { couvre, repartirSieges, reservationsActives } from './places.js'; import { controlerReintegration, leverRefus } from './pose.js'; @@ -378,6 +379,54 @@ function importerParticipants(charge, { apercu, mode }) { }; } +// Le remède d'un refus du CSV des objectifs, par code : un événement sans +// table se corrige dans la section des tables ; les autres se corrigent dans +// le fichier ou l'association des colonnes, et n'en ont pas. +const REMEDES_DES_OBJECTIFS = new Map([['OBJECTIFS_SANS_TABLE', { geste: 'ouvrirSection', section: 'tables' }]]); + +/** + * importerObjectifs {apercu, mode} (§ 10.4) : l'aperçu du CSV des objectifs + * appliqué selon le mode, mettreAJour ou remplacer (appliquerImportObjectifs), + * en une seule entrée. Le refus du CSV — un aperçu refusé ou ambigu — devient + * le refus de la commande, sous le même code, remède null sauf OBJECTIFS_SANS_TABLE, + * qui mène à la section des tables. Le résumé suit le rendu, pour le rapport + * d'import et le CSV des refus. Selon l'état : sans avertissement en + * brouillon et proposé ; en retenu, IMPORT_OBJECTIFS_RECLASSE, le classement + * des propositions pouvant changer, sans qu'aucune passe en dérive (§ 8.9) ; + * refusé en bloqué, PLAN_BLOQUE. Suivent, dans l'ordre d'avisObjectifs, les + * avis OBJECTIF_SANS_CORRESPONDANT des seuls objectifs que le fichier écrit. + * + * Un import dont la charge ne change pas, mais qui refuse des lignes, rend la + * charge reçue, un libellé null — aucune entrée ne s'écrit —, aucun + * avertissement, et son résumé. + */ +function importerObjectifs(charge, { apercu, mode }) { + exigerModifiable(charge); + let resultat; + try { + resultat = appliquerImportObjectifs(charge, apercu, mode); + } catch (erreur) { + if (erreur instanceof ErreurCsv) { + throw new ErreurCommande(erreur.code, { ...erreur.details, remede: REMEDES_DES_OBJECTIFS.get(erreur.code) ?? null }); + } + throw erreur; + } + const { ajoutes, remplaces, retires, derive, refusees } = resultat.resume; + if (refusees.length > 0 && serialiserCharge(resultat.charge) === serialiserCharge(charge)) { + return { charge, libelle: null, avertissements: [], resume: resultat.resume }; + } + const ecrits = new Set(apercu.lignes.map(({ champs }) => JSON.stringify([champs.table, champs.tour]))); + const sansCorrespondant = avisObjectifs(resultat.charge).filter( + ({ code, details }) => code === 'OBJECTIF_SANS_CORRESPONDANT' && ecrits.has(JSON.stringify([details.table, details.tour])), + ); + return { + charge: resultat.charge, + libelle: libelle('ENTREE_IMPORTER_OBJECTIFS', { mode, ajoutes, remplaces, retires }), + avertissements: [...(derive ? [avertissement('IMPORT_OBJECTIFS_RECLASSE', {})] : []), ...sansCorrespondant], + resume: resultat.resume, + }; +} + /** changerEtat {etat} (§ 9) : un état du plan, sans rien détruire ; bloque se pose de chacun. */ function changerEtat(charge, { etat }) { if (!ETATS.includes(etat)) throw new TypeError(`etat : ${ETATS.join(', ')} attendu, reçu ${JSON.stringify(etat)}`); @@ -532,8 +581,8 @@ export function reunirCommandes(...registres) { /** * Le registre des commandes, par nom : chacune (charge, arguments) → - * { charge, libelle, avertissements }, et importerParticipants y ajoute le - * résumé de l'import. Il réunit les commandes de ce module, celles du + * { charge, libelle, avertissements }, et importerParticipants et + * importerObjectifs y ajoutent le résumé de l'import. Il réunit les commandes de ce module, celles du * mobilier et celles des places. * * @typedef {{code: string, details: Object}} Avertissement @@ -549,6 +598,7 @@ export const COMMANDES = reunirCommandes( ['reintegrerParticipant', reintegrerParticipant], ['supprimerParticipant', supprimerParticipant], ['importerParticipants', importerParticipants], + ['importerObjectifs', importerObjectifs], ['changerEtat', changerEtat], ['debloquer', debloquer], ['enregistrerGeneration', enregistrerGeneration], diff --git a/src/application/commandes.test.js b/src/application/commandes.test.js index 164eb98..bfd8c16 100644 --- a/src/application/commandes.test.js +++ b/src/application/commandes.test.js @@ -10,6 +10,7 @@ import assert from 'node:assert/strict'; import { describe, test } from '../../test/lanceur.js'; import { apercevoir } from '../csv/apercu.js'; import { bilanRemplacement } from '../csv/import.js'; +import { apercevoirObjectifs } from '../csv/objectifs.js'; import { rechercher } from '../moteur/recherche.js'; import { serialiserCharge } from '../stockage/canonique.js'; import { configurationDepuisCharge, creerCharge, etatDeduit } from '../stockage/document.js'; @@ -111,6 +112,7 @@ describe('commandes : le registre (§ 8.2)', () => { 'effacerPropositions', 'enregistrerGeneration', 'exclureParticipant', + 'importerObjectifs', 'importerParticipants', 'modifierParticipant', 'poserTitre', @@ -122,7 +124,7 @@ describe('commandes : le registre (§ 8.2)', () => { 'retirerTitre', 'supprimerParticipant', ]; - assert.equal(attendues.length, 22); + assert.equal(attendues.length, 23); assert.deepEqual([...COMMANDES.keys()].sort(), [...attendues, ...COMMANDES_MOBILIER.keys()].sort()); for (const commande of COMMANDES.values()) assert.equal(typeof commande, 'function'); }); @@ -517,6 +519,90 @@ describe("commandes : l'import qui exclut, réintègre ou ne change rien (§ 4.4 }); }); +describe("commandes : l'import des objectifs de table (§ 10.4)", () => { + // La charge d'essai, Iris Ombrelle étiquetée « finance », Théo Pervenche + // « Montréal », et la table 1 portant un objectif de soirée. + function etiquetee(charge = chargeEssai()) { + const suivante = structuredClone(charge); + suivante.participants[0].etiquettes = ['finance']; + suivante.participants[2].etiquettes = ['Montréal']; + suivante.tables[0].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }]; + return suivante; + } + const importer = (charge, texte, mode = 'mettreAJour') => + appliquerCommande('importerObjectifs', charge, { apercu: apercevoirObjectifs(texte, charge, { mode }), mode }); + + test('mettre à jour et remplacer : une seule commande, le libellé nomme le mode et les comptes, le résumé suit', () => { + const avant = figer(etiquetee()); + const misAJour = importer(avant, 'table;tour;objectif;regle\n1;;finance, Montréal;ou\n2;2;MONTRÉAL;\n'); + assert.equal(misAJour.libelle, 'Mis à jour les objectifs de table par import : 1 ajouté, 1 remplacé'); + assert.deepEqual(misAJour.charge.tables.map(({ objectifs }) => objectifs), [ + [{ portee: 'tous', tour: null, regle: 'ou', etiquettes: ['Montréal', 'finance'] }], + [{ portee: 'tour', tour: 2, regle: null, etiquettes: ['Montréal'] }], + ]); + assert.deepEqual(misAJour.avertissements, []); + assert.deepEqual( + [misAJour.resume.ajoutes, misAJour.resume.remplaces, misAJour.resume.inchanges, misAJour.resume.tables], + [1, 1, 0, 2], + ); + const remplace = importer(avant, 'table;tour;objectif\n2;1;finance\n', 'remplacer'); + assert.equal(remplace.libelle, 'Remplacé les objectifs de table par import : 1 retiré, 1 ajouté, 0 remplacé'); + assert.ok(!Object.hasOwn(remplace.charge.tables[0], 'objectifs')); + assert.deepEqual(remplace.charge.tables[1].objectifs, [{ portee: 'tour', tour: 1, regle: null, etiquettes: ['finance'] }]); + assert.deepEqual(remplace.charge.participants, avant.participants); + }); + + test("en retenu, IMPORT_OBJECTIFS_RECLASSE, et aucune proposition ni le retenu ne passent en dérive ; rien en brouillon et proposé ; refusé en bloqué", () => { + const retenue = etiquetee(chargeRiche()); + assert.equal(retenue.evenement.etat, 'retenu'); + const raisons = (charge) => [...charge.propositions, charge.retenu].map((placement) => derive(placement, charge)); + const rendu = importer(retenue, 'table;tour;objectif\n2;;finance\n1;2;Montréal\n'); + assert.deepEqual(rendu.avertissements, [{ code: 'IMPORT_OBJECTIFS_RECLASSE', details: {} }]); + assert.deepEqual(raisons(rendu.charge), raisons(retenue)); + assert.deepEqual(rendu.charge.propositions, retenue.propositions); + assert.deepEqual(rendu.charge.retenu, retenue.retenu); + const proposee = etiquetee(executer('enregistrerGeneration', chargeEssai(), generer(chargeEssai(), 2)).charge); + assert.equal(proposee.evenement.etat, 'propose'); + assert.deepEqual(importer(proposee, 'table;objectif\n2;finance\n').avertissements, []); + const bloquee = { ...retenue, evenement: { ...retenue.evenement, etat: 'bloque' } }; + refuse(() => importer(bloquee, 'table;objectif\n2;finance\n'), 'PLAN_BLOQUE', { remede: { geste: 'debloquer' } }); + }); + + test("l'avis OBJECTIF_SANS_CORRESPONDANT des seuls objectifs écrits, relayé d'avisObjectifs, après la dérive", () => { + const charge = etiquetee(chargeRiche()); + // La table 1 garde un objectif que personne ne sert : il ne se redit pas. + charge.tables[0].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['voile'] }]; + const rendu = importer(charge, 'table;tour;objectif;regle\n2;;finance, Montréal;et\n2;1;finance;\n'); + assert.deepEqual(rendu.avertissements, [ + { code: 'IMPORT_OBJECTIFS_RECLASSE', details: {} }, + { code: 'OBJECTIF_SANS_CORRESPONDANT', details: { table: 2, tour: null } }, + ]); + }); + + test("un aperçu refusé ou ambigu est refusé sous son code, remède null ; OBJECTIFS_SANS_TABLE mène à la section des tables", () => { + const avant = etiquetee(); + refuse(() => importer(avant, 'tour;objectif\n1;finance\n'), 'TABLE_NON_ASSOCIEE', { ligne: null, remede: null }); + refuse(() => importer(avant, 'table;objectif\n1;"finance\n'), 'GUILLEMET_OUVERT', { ligne: 2, remede: null }); + refuse(() => importer(avant, 'table;regle;règle;objectif\n1;ou;et;finance\n'), 'AMBIGUITE', { champs: ['regle'], remede: null }); + const sansTable = { ...avant, tables: [] }; + refuse(() => importer(sansTable, 'table;objectif\n1;finance\n'), 'OBJECTIFS_SANS_TABLE', { + ligne: null, + remede: { geste: 'ouvrirSection', section: 'tables' }, + }); + }); + + test("un import qui ne change rien est SANS_EFFET ; qui ne change rien mais refuse des lignes rend son résumé, sans entrée", () => { + const avant = figer(etiquetee()); + refuse(() => importer(avant, 'table;objectif\n1;finance\n'), 'SANS_EFFET', { commande: 'importerObjectifs', remede: null }); + const rendu = importer(avant, 'table;tour;objectif;regle\n1;;FINANCE;\n2;;finance;peut-être\n'); + assert.equal(rendu.libelle, null); + assert.equal(rendu.charge, avant); + assert.deepEqual(rendu.avertissements, []); + assert.deepEqual(rendu.resume.refusees.map(({ ligne, code }) => [ligne, code]), [[3, 'REGLE_INCONNUE']]); + assert.equal(rendu.resume.inchanges, 1); + }); +}); + describe('commandes : la forme nommée, une lecture (§ 8.9, point 4)', () => { test("rend la forme nommée d'une proposition ou du retenu, avec ses fautes, sans toucher la charge", () => { const charge = figer(chargeRiche()); @@ -721,6 +807,17 @@ describe('commandes : la charge reçue ne change jamais (§ 13.2)', () => { }; } + // Un ordre des priorités réglé : l'excédent et les collisions cumulées + // partagent le deuxième palier, pondérés. + const PRIORITES_REGLEES = [ + [{ critere: 'ecartAuPlafondAPrioriMax', poids: 1 }], + [{ critere: 'excedentCollisions', poids: 2 }, { critere: 'collisionsCumulees', poids: 3 }], + [{ critere: 'toursHorsObjectif', poids: 1 }], + [{ critere: 'rencontresRepetees', poids: 1 }], + [{ critere: 'animateursNonRencontres', poids: 1 }], + [{ critere: 'redondance', poids: 1 }], + ]; + // La charge que reçoit chaque commande : la charge riche, sauf debloquer, // qui la reçoit bloquée ; convertirEnTous, qui y trouve Théo réservé à la // table 2 à chaque tour, en portée « tour » ; deplacerDansRetenu, qui y @@ -737,6 +834,7 @@ describe('commandes : la charge reçue ne change jamais (§ 13.2)', () => { ['convertirEnTous', reservee], ['deplacerDansRetenu', executer('retirerDuTour', charge, { tour: 1, participant: autre }).charge], ['retirerObjectif', executer('poserObjectif', charge, { table: 2, tour: null, etiquettes: 'finance', regle: null }).charge], + ['retablirPriorites', executer('changerPriorites', charge, { paliers: PRIORITES_REGLEES }).charge], ]); } @@ -750,6 +848,7 @@ describe('commandes : la charge reçue ne change jamais (§ 13.2)', () => { ['reintegrerParticipant', { id: 5 }], ['supprimerParticipant', { id: 2 }], ['importerParticipants', { apercu: apercevoir(CSV_DEUX, { participants: charge.participants }), mode: 'ajouter' }], + ['importerObjectifs', { apercu: apercevoirObjectifs('table;objectif\n2;finance\n', charge), mode: 'mettreAJour' }], ['changerEtat', { etat: 'bloque' }], ['debloquer', {}], ['enregistrerGeneration', generer(charge, 1, 5)], @@ -778,6 +877,8 @@ describe('commandes : la charge reçue ne change jamais (§ 13.2)', () => { ['poserObjectif', { table: 2, tour: 1, etiquettes: 'finance', regle: 'et' }], ['retirerObjectif', { table: 2, tour: null }], ['changerRegleObjectifs', { regle: 'et' }], + ['changerPriorites', { paliers: PRIORITES_REGLEES }], + ['retablirPriorites', {}], ]); } @@ -825,8 +926,8 @@ describe('commandes : les registres réunis et les erreurs partagées', () => { ); }); - test("COMMANDES réunit les onze commandes d'A1, changerEtiquettes et les deux registres de F1", () => { - assert.equal(COMMANDES.size, 12 + COMMANDES_MOBILIER.size + COMMANDES_PLACES.size); + test("COMMANDES réunit les onze commandes d'A1, changerEtiquettes, importerObjectifs et les deux registres de F1", () => { + assert.equal(COMMANDES.size, 13 + COMMANDES_MOBILIER.size + COMMANDES_PLACES.size); assert.ok(COMMANDES_MOBILIER instanceof Map); assert.ok(COMMANDES_PLACES instanceof Map); for (const registre of [COMMANDES_MOBILIER, COMMANDES_PLACES]) { diff --git a/src/application/commandes_mobilier.js b/src/application/commandes_mobilier.js index 14b8f64..8f32d96 100644 --- a/src/application/commandes_mobilier.js +++ b/src/application/commandes_mobilier.js @@ -3,8 +3,8 @@ // Le registre des commandes du mobilier — tables, sièges, tours, réglages, // cible de la somme de redondance, objectifs des tables et leur règle par -// défaut (§ 4, § 4.5, § 6, § 7.1, § 8.2, § 8.9, § 9), que commandes.js réunit -// aux autres. Ses imports n'atteignent jamais commandes.js : le registre +// défaut, ordre des priorités et mode des objectifs (§ 4, § 4.5, § 5.3, +// § 5.7, § 6, § 7.1, § 8.2, § 8.9, § 9), que commandes.js réunit aux autres. Ses imports n'atteignent jamais commandes.js : le registre // réuni dépend de lui, non l'inverse. // // Chaque commande suit le contrat de commandes.js : une fonction pure @@ -25,6 +25,7 @@ // proposition ne se réécrit jamais : elle dérive (§ 8.9, § 9). Supprimer une // table et changer les tours laissent propositions et retenu en dérive. import { caseLibre, geometrieTable, listeSurTable } from '../geometrie/tables.js'; +import { CRITERES, MODES_OBJECTIFS, POIDS_MAX, seulsASonPalier, verifierPriorites } from '../moteur/criteres.js'; import { CIBLE_REDONDANCE_PAR_DEFAUT, SCHEMA, @@ -32,6 +33,8 @@ import { capacite, cibleRedondance, critereCoche, + modeObjectifs, + priorites, regleObjectifs, } from '../stockage/document.js'; import { ETIQUETTES_MAX } from '../stockage/etiquettes.js'; @@ -63,6 +66,9 @@ const defautDeLaCase = (cle) => critereCoche({ reglages: {} }, cle); const REGLES = new Set(['ou', 'et']); +// Le mode des objectifs que les réglages ne portent pas (§ 8.9). +const MODE_PAR_DEFAUT = modeObjectifs({ reglages: {} }); + // Un plan bloqué refuse toute modification ; débloquer seul le lève (§ 9). function exigerModifiable(charge) { if (charge.evenement.etat === 'bloque') throw refus('PLAN_BLOQUE', {}, { geste: 'debloquer' }); @@ -343,7 +349,8 @@ function changerTours(charge, { tours: saisie } = {}) { * contraintes ou de l'attribution des sièges, un booléen ; l'une des deux * cases facultatives, respecterObjectifs ou rencontrerAnimateurs, un * booléen écrit seulement hors de son défaut — y revenir retire le champ, et - * la valeur courante est SANS_EFFET ; ou generation.nombre, + * la valeur courante est SANS_EFFET ; le mode des objectifs, modeObjectifs, + * l'un de MODES_OBJECTIFS (changerModeObjectifs) ; ou generation.nombre, * generation.arret, generation.historique, un entier ≥ 1, que le contrôleur * de génération a lu et borné. Une autre clé, ou une autre valeur, est une * faute du code. @@ -359,6 +366,8 @@ function modifierReglage(charge, { cle, valeur } = {}) { if (valeur === critereCoche(charge, cle)) throw refus('SANS_EFFET', { commande: 'modifierReglage' }); if (valeur === defautDeLaCase(cle)) delete suivante.reglages[cle]; else suivante.reglages[cle] = valeur; + } else if (cle === 'modeObjectifs') { + changerModeObjectifs(charge, suivante, valeur); } else if (ENTIERS_DE_GENERATION.has(cle)) { if (!Number.isSafeInteger(valeur) || valeur < 1) throw new TypeError(`${cle} : entier ≥ 1 attendu, reçu ${JSON.stringify(valeur)}`); suivante.reglages.generation[cle.slice('generation.'.length)] = valeur; @@ -509,6 +518,189 @@ function changerRegleObjectifs(charge, { regle } = {}) { }; } +// --- L'ordre des priorités et le mode des objectifs (§ 5.3, § 5.7, § 5.9) --- + +// La liste que reçoit changerPriorites, contrôlée dans sa forme : une liste +// de paliers, chacun une liste d'entrées {critere, poids}, critere une +// chaîne, poids un nombre. Une autre forme est une faute du code, que +// l'éditeur n'offre jamais : TypeError. Rend une copie, chaque entrée réduite +// à ces deux clés, dans l'ordre reçu. +function lirePaliers(paliers) { + const faute = (attendu) => new TypeError(`paliers : ${attendu}, reçu ${JSON.stringify(paliers)}`); + if (!Array.isArray(paliers)) throw faute('une liste de paliers attendue'); + return paliers.map((palier) => { + if (!Array.isArray(palier)) throw faute('chaque palier, une liste'); + return palier.map((entree) => { + const forme = + typeof entree === 'object' && + entree !== null && + !Array.isArray(entree) && + typeof entree.critere === 'string' && + typeof entree.poids === 'number'; + if (!forme) throw faute('chaque entrée, {critere: chaîne, poids: nombre}'); + return { critere: entree.critere, poids: entree.poids }; + }); + }); +} + +// Vrai pour un poids du domaine : un entier de 1 à POIDS_MAX (§ 5.7). +const poidsAdmis = (poids) => Number.isInteger(poids) && poids >= 1 && poids <= POIDS_MAX; + +// Le rang d'un critère dans le registre ; −1 pour un nom inconnu, que +// verifierPriorites relève ensuite. +const rangDuCritere = (nom) => CRITERES.indexOf(nom); + +// Une liste de priorités rangée : les critères de chaque palier dans l'ordre +// du registre (A16), et le poids d'un critère seul à son palier ramené à 1. +// Seul, un poids multiplie la seule valeur de son palier et ne départage +// rien : deux listes de même effet s'écrivent pareil, et un mode sous lequel +// le critère compare deux valeurs, qui ne le pondère pas (D8), l'admet. Rend +// une liste neuve. +function rangerPaliers(liste) { + return liste.map((palier) => { + const range = [...palier].sort((a, b) => rangDuCritere(a.critere) - rangDuCritere(b.critere)); + return range.length === 1 ? [{ critere: range[0].critere, poids: 1 }] : range.map(({ critere, poids }) => ({ critere, poids })); + }); +} + +// Deux listes rangées identiques, palier par palier, entrée par entrée. +const memesPriorites = (a, b) => + a.length === b.length && + a.every((palier, i) => palier.length === b[i].length && palier.every(({ critere, poids }, k) => critere === b[i][k].critere && poids === b[i][k].poids)); + +// L'ordre par défaut, rangé : celui que lit priorites sur des réglages qui +// ne portent pas le champ. +const PAR_DEFAUT = rangerPaliers(priorites({ reglages: {} })); + +// La place de chaque critère d'une liste rangée : l'index de son palier, +// ses compagnons de palier et son poids. +function placesDes(liste) { + const places = new Map(); + liste.forEach((palier, index) => { + for (const { critere, poids } of palier) { + const compagnons = palier.map((autre) => autre.critere).filter((autre) => autre !== critere); + places.set(critere, { index, compagnons, poids }); + } + }); + return places; +} + +// Les détails du libellé figé de changerPriorites : le critère que le geste +// déplace, ou les critères du palier déplacé entier, l'index du palier où il +// arrive, le nombre de paliers, ses compagnons d'arrivée, et son poids quand +// le geste ne change que lui. avant et apres sont deux listes rangées +// différentes ; les candidats se lisent dans l'ordre d'apres. +// - Joindre, séparer : parmi les critères dont les compagnons changent, +// celui qui en gagne ou en perd le plus — le critère joint ou séparé, et +// non ceux du palier qu'il quitte ou rejoint, qui n'en gagnent ou n'en +// perdent qu'un — ; à égalité, celui dont le palier change d'index. +// - Déplacer un palier sans rien regrouper : celui qui va le plus loin ; à +// égalité, celui qui monte — monter d'un palier passe le palier voisin, +// qui descend d'autant. +// - Rien d'autre : le critère dont le poids change. +function changementDesPriorites(avant, apres) { + const ancienne = placesDes(avant); + const nouvelle = placesDes(apres); + const noms = apres.flat().map(({ critere }) => critere); + const ecartDIndex = (nom) => nouvelle.get(nom).index - ancienne.get(nom).index; + const paliers = apres.length; + const changes = (nom) => { + const [gardes, prises] = [ancienne.get(nom).compagnons, nouvelle.get(nom).compagnons]; + return gardes.filter((autre) => !prises.includes(autre)).length + prises.filter((autre) => !gardes.includes(autre)).length; + }; + const regroupes = noms.filter((nom) => changes(nom) > 0); + if (regroupes.length > 0) { + const plus = Math.max(...regroupes.map(changes)); + const candidats = regroupes.filter((nom) => changes(nom) === plus); + const nom = candidats.find((candidat) => ecartDIndex(candidat) !== 0) ?? candidats[0]; + const { index, compagnons } = nouvelle.get(nom); + return { criteres: [nom], palier: index, paliers, avec: compagnons, poids: null }; + } + const deplaces = noms.filter((nom) => ecartDIndex(nom) !== 0); + if (deplaces.length > 0) { + const loin = Math.max(...deplaces.map((nom) => Math.abs(ecartDIndex(nom)))); + const candidats = deplaces.filter((nom) => Math.abs(ecartDIndex(nom)) === loin); + const { index } = nouvelle.get(candidats.find((nom) => ecartDIndex(nom) < 0) ?? candidats[0]); + return { criteres: apres[index].map(({ critere }) => critere), palier: index, paliers, avec: [], poids: null }; + } + const nom = noms.find((candidat) => nouvelle.get(candidat).poids !== ancienne.get(candidat).poids); + const { index, poids } = nouvelle.get(nom); + return { criteres: [nom], palier: index, paliers, avec: [], poids }; +} + +/** + * changerPriorites {paliers} (§ 5.7, § 5.9, A11, A12) : l'ordre des + * priorités de l'événement, une liste complète de paliers, chacun une liste + * d'entrées {critere, poids}. Elle s'écrit rangée (rangerPaliers) ; égale à + * l'ordre par défaut, elle retire le champ des réglages. Une liste dont + * l'effet égale l'ordre courant (priorites) est SANS_EFFET. Refus, remède + * null, dans cet ordre : POIDS_HORS_DOMAINE {valeur}, le premier poids qui + * n'est pas un entier de 1 à POIDS_MAX ; PRIORITES_INVALIDES {faute, + * critere?, palier?}, la faute que verifierPriorites relève sous le mode des + * objectifs de l'événement — palier, l'index du palier à partir de 0. Une + * liste de forme fausse est une faute du code (lirePaliers). Le libellé + * figé nomme le critère déplacé et son palier (changementDesPriorites). + */ +function changerPriorites(charge, { paliers } = {}) { + exigerModifiable(charge); + const recue = lirePaliers(paliers); + const horsDomaine = recue.flat().find(({ poids }) => !poidsAdmis(poids)); + if (horsDomaine !== undefined) throw refus('POIDS_HORS_DOMAINE', { valeur: horsDomaine.poids }); + const liste = rangerPaliers(recue); + const faute = verifierPriorites(liste, modeObjectifs(charge)); + if (faute !== null) throw refus('PRIORITES_INVALIDES', faute); + const courante = rangerPaliers(priorites(charge)); + if (memesPriorites(liste, courante)) throw refus('SANS_EFFET', { commande: 'changerPriorites' }); + const suivante = structuredClone(charge); + if (memesPriorites(liste, PAR_DEFAUT)) delete suivante.reglages.priorites; + else suivante.reglages.priorites = liste; + return { + charge: suivante, + libelle: libelle('ENTREE_CHANGER_PRIORITES', changementDesPriorites(courante, liste)), + avertissements: [], + }; +} + +/** + * retablirPriorites {} (§ 5.7, D19) : l'ordre par défaut, sans fenêtre à + * confirmer — un retour à l'instant précédent rend l'ordre réglé. Le champ + * des réglages part ; sans lui, SANS_EFFET. + */ +function retablirPriorites(charge) { + exigerModifiable(charge); + if (charge.reglages.priorites === undefined) throw refus('SANS_EFFET', { commande: 'retablirPriorites' }); + const suivante = structuredClone(charge); + delete suivante.reglages.priorites; + return { charge: suivante, libelle: libelle('ENTREE_RETABLIR_PRIORITES'), avertissements: [] }; +} + +// modifierReglage {cle: 'modeObjectifs', valeur} (§ 5.3, § 8.9, A12, D23), +// sur suivante, la copie de charge : valeur, l'un de MODES_OBJECTIFS, sinon +// TypeError ; le mode courant est SANS_EFFET ; le défaut retire le champ. +// Un mode sous lequel un critère se range seul à son palier — les tours hors +// objectif sous les modes qui comparent deux valeurs — se refuse tant que ce +// critère partage le sien : OBJECTIFS_PALIER_PARTAGE {mode, palier}, palier +// à partir de 0, remède null, le mode inchangé. Séparer d'office déplacerait +// des critères que le choix ne nomme pas : aucun critère ne se déplace. Un +// ordre écrit se réécrit rangé (rangerPaliers) : le poids d'un critère seul, +// qu'un fichier écrit ailleurs peut porter, revient à 1, et l'ordre reste +// valide sous le mode choisi. +function changerModeObjectifs(charge, suivante, valeur) { + if (!MODES_OBJECTIFS.includes(valeur)) { + throw new TypeError(`modeObjectifs : ${MODES_OBJECTIFS.join(', ')} attendu, reçu ${JSON.stringify(valeur)}`); + } + if (valeur === modeObjectifs(charge)) throw refus('SANS_EFFET', { commande: 'modifierReglage' }); + const liste = rangerPaliers(priorites(charge)); + const seuls = seulsASonPalier(valeur); + const partage = liste.findIndex((palier) => palier.length > 1 && palier.some(({ critere }) => seuls.includes(critere))); + if (partage >= 0) throw refus('OBJECTIFS_PALIER_PARTAGE', { mode: valeur, palier: partage }); + if (valeur === MODE_PAR_DEFAUT) delete suivante.reglages.modeObjectifs; + else suivante.reglages.modeObjectifs = valeur; + if (charge.reglages.priorites === undefined) return; + if (memesPriorites(liste, PAR_DEFAUT)) delete suivante.reglages.priorites; + else suivante.reglages.priorites = liste; +} + /** @type {Map Object>} */ export const COMMANDES_MOBILIER = new Map([ ['creerTable', creerTable], @@ -523,4 +715,6 @@ export const COMMANDES_MOBILIER = new Map([ ['poserObjectif', poserObjectif], ['retirerObjectif', retirerObjectif], ['changerRegleObjectifs', changerRegleObjectifs], + ['changerPriorites', changerPriorites], + ['retablirPriorites', retablirPriorites], ]); diff --git a/src/application/commandes_mobilier.test.js b/src/application/commandes_mobilier.test.js index de7fd42..59961f0 100644 --- a/src/application/commandes_mobilier.test.js +++ b/src/application/commandes_mobilier.test.js @@ -15,6 +15,7 @@ import { describe, test } from '../../test/lanceur.js'; import { texteLivre } from '../demo/livrees.js'; import { caseLibre } from '../geometrie/tables.js'; import { STATUT, nombrePlacesManquantes, normaliser } from '../moteur/configuration.js'; +import { CRITERES } from '../moteur/criteres.js'; import { ErreurConfiguration } from '../moteur/erreurs.js'; import { serialiser } from '../stockage/canonique.js'; import { creer as creerDansLeDepot } from '../stockage/depot.js'; @@ -26,6 +27,8 @@ import { cibleRedondance, configurationDepuisCharge, creerCharge, + modeObjectifs, + priorites, } from '../stockage/document.js'; import { lireJournal } from '../stockage/journal.js'; import { SUFFIXES } from '../stockage/noms.js'; @@ -214,7 +217,7 @@ async function seanceSur(charge) { // --- Les épreuves ----------------------------------------------------------- describe('mobilier : le registre (§ 8.2)', () => { - test('COMMANDES_MOBILIER porte les douze commandes du mobilier, réunies dans COMMANDES', () => { + test('COMMANDES_MOBILIER porte les quatorze commandes du mobilier, réunies dans COMMANDES', () => { const noms = [ 'creerTable', 'supprimerTable', @@ -228,6 +231,8 @@ describe('mobilier : le registre (§ 8.2)', () => { 'poserObjectif', 'retirerObjectif', 'changerRegleObjectifs', + 'changerPriorites', + 'retablirPriorites', ]; assert.deepEqual([...COMMANDES_MOBILIER.keys()], noms); for (const nom of noms) assert.equal(COMMANDES.get(nom), COMMANDES_MOBILIER.get(nom), nom); @@ -249,6 +254,8 @@ describe('mobilier : le registre (§ 8.2)', () => { ['poserObjectif', { table: 1, tour: null, etiquettes: 'finance', regle: null }], ['retirerObjectif', { table: 1, tour: null }], ['changerRegleObjectifs', { regle: 'et' }], + ['changerPriorites', { paliers: [] }], + ['retablirPriorites', {}], ]; assert.equal(gestes.length, COMMANDES_MOBILIER.size); for (const [nom, args] of gestes) { @@ -1454,3 +1461,264 @@ describe('mobilier : retirer des tours détruit leurs objectifs (§ 2.1 étape 5 assert.equal(accepter('changerTours', charge, { tours: 4 }).libelle, 'Nombre de tours : 4'); }); }); + +// --- L'ordre des priorités et le mode des objectifs (§ 5.3, § 5.7, § 5.9, A11, A12, D19, D23) --- + +const ECART = 'ecartAuPlafondAPrioriMax'; +const EXCEDENT = 'excedentCollisions'; +const CUMULEES = 'collisionsCumulees'; +const HORS = 'toursHorsObjectif'; +const REPETEES = 'rencontresRepetees'; +const ANIMATEURS = 'animateursNonRencontres'; +const REDONDANCE = 'redondance'; + +// Une liste de priorités : chaque palier, un nom seul au poids 1, ou une +// liste de [nom, poids]. +const ordre = (...paliers) => + paliers.map((palier) => + typeof palier === 'string' ? [{ critere: palier, poids: 1 }] : palier.map(([critere, poids]) => ({ critere, poids })), + ); +const DEFAUT = ordre(...CRITERES); +// L'excédent et les collisions cumulées ensemble, pondérés, au palier 2. +const COLLISIONS_JOINTES = ordre(ECART, [[EXCEDENT, 2], [CUMULEES, 3]], HORS, REPETEES, ANIMATEURS, REDONDANCE); +// Les tours hors objectif joints à l'excédent, au palier 2. +const HORS_JOINTS = ordre(ECART, [[EXCEDENT, 1], [HORS, 3]], CUMULEES, REPETEES, ANIMATEURS, REDONDANCE); + +const texteDe = (charge) => serialiser(charge, { revision: 1, produitVersion: V1 }); + +describe("mobilier : l'ordre des priorités (§ 5.7, § 5.9, A11, A12, D19)", () => { + test("un ordre réglé s'écrit, chaque palier rangé dans l'ordre du registre ; le défaut écrit retire le champ ; l'ordre courant est SANS_EFFET", () => { + const charge = chargeEtiquetee(); + const melange = ordre(ECART, [[CUMULEES, 3], [EXCEDENT, 2]], HORS, REPETEES, ANIMATEURS, REDONDANCE); + const regle = accepter('changerPriorites', charge, { paliers: melange }); + assert.deepEqual(regle.charge.reglages.priorites, COLLISIONS_JOINTES); + assert.equal(regle.libelle, 'Ordre des priorités : « collisions cumulées » au palier 2 sur 6, avec « excédent de collisions »'); + assert.deepEqual(regle.avertissements, []); + assert.equal(texteDe(accepter('changerPriorites', charge, { paliers: COLLISIONS_JOINTES }).charge), texteDe(regle.charge)); + + const defaut = accepter('changerPriorites', regle.charge, { paliers: DEFAUT }); + assert.equal(Object.hasOwn(defaut.charge.reglages, 'priorites'), false); + assert.equal(texteDe(defaut.charge), texteDe(charge)); + assert.equal(defaut.libelle, 'Ordre des priorités : « collisions cumulées » au palier 3 sur 7'); + + refuse(() => COMMANDES.get('changerPriorites')(charge, { paliers: DEFAUT }), 'SANS_EFFET', { commande: 'changerPriorites', remede: null }); + refuse(() => COMMANDES.get('changerPriorites')(regle.charge, { paliers: melange }), 'SANS_EFFET'); + }); + + test("le libellé figé nomme le critère déplacé et son palier, ou son poids : monter, joindre, séparer, pondérer, déplacer un palier entier", () => { + const charge = chargeEtiquetee(); + const libelleDe = (avant, paliers) => accepter('changerPriorites', avant, { paliers }).libelle; + // Monter d'un palier : le critère qui monte est nommé, celui qu'il + // passe ne l'est pas. + assert.equal( + libelleDe(charge, ordre(ECART, EXCEDENT, CUMULEES, REPETEES, HORS, ANIMATEURS, REDONDANCE)), + 'Ordre des priorités : « rencontres répétées et retours » au palier 4 sur 7', + ); + // Un palier porté d'un bout à l'autre. + assert.equal( + libelleDe(charge, ordre(REDONDANCE, ECART, EXCEDENT, CUMULEES, HORS, REPETEES, ANIMATEURS)), + "Ordre des priorités : « redondance d'appartenance » au palier 1 sur 7", + ); + assert.equal( + libelleDe(charge, ordre(ECART, EXCEDENT, CUMULEES, HORS, REPETEES, REDONDANCE, ANIMATEURS)), + "Ordre des priorités : « redondance d'appartenance » au palier 6 sur 7", + ); + const jointe = accepter('changerPriorites', charge, { paliers: COLLISIONS_JOINTES }).charge; + // Le poids d'un critère d'un palier partagé. + assert.equal( + libelleDe(jointe, ordre(ECART, [[EXCEDENT, 2], [CUMULEES, 5]], HORS, REPETEES, ANIMATEURS, REDONDANCE)), + 'Ordre des priorités : « collisions cumulées » au poids 5, palier 2 sur 6', + ); + // Un palier partagé, déplacé entier : ses critères sont nommés ensemble. + assert.equal( + libelleDe(jointe, ordre(ECART, HORS, REPETEES, [[EXCEDENT, 2], [CUMULEES, 3]], ANIMATEURS, REDONDANCE)), + 'Ordre des priorités : « excédent de collisions » et « collisions cumulées » au palier 4 sur 6', + ); + // Joindre au palier du dessus un critère seul : il est nommé, avec ceux + // qu'il rejoint. + assert.equal( + libelleDe(jointe, ordre(ECART, [[EXCEDENT, 2], [CUMULEES, 3], [HORS, 1]], REPETEES, ANIMATEURS, REDONDANCE)), + 'Ordre des priorités : « tours hors objectif » au palier 2 sur 5, avec « excédent de collisions » et « collisions cumulées »', + ); + }); + + test("le poids d'un critère seul à son palier s'écrit 1 : il ne départage rien", () => { + const jointe = accepter('changerPriorites', chargeEtiquetee(), { paliers: COLLISIONS_JOINTES }).charge; + const separee = accepter('changerPriorites', jointe, { + paliers: ordre(ECART, [[EXCEDENT, 2]], [[CUMULEES, 3]], [[HORS, 40]], REPETEES, ANIMATEURS, REDONDANCE), + }); + assert.equal(Object.hasOwn(separee.charge.reglages, 'priorites'), false); + // Un poids seul, au défaut près, ne change rien. + refuse( + () => COMMANDES.get('changerPriorites')(chargeEtiquetee(), { paliers: ordre(ECART, [[EXCEDENT, 7]], CUMULEES, HORS, REPETEES, ANIMATEURS, REDONDANCE) }), + 'SANS_EFFET', + ); + }); + + test('une liste invalide est PRIORITES_INVALIDES {faute, critere, palier}, remède null : écart joint, rencontres répétées jointes, critère absent, répété, inconnu, palier vide', () => { + const charge = figer(chargeEtiquetee()); + const cas = [ + [ordre([[ECART, 1], [EXCEDENT, 1]], CUMULEES, HORS, REPETEES, ANIMATEURS, REDONDANCE), { faute: 'seulASonPalier', critere: ECART, palier: 0 }], + [ordre(ECART, EXCEDENT, CUMULEES, HORS, [[REPETEES, 1], [REDONDANCE, 4]], ANIMATEURS), { faute: 'seulASonPalier', critere: REPETEES, palier: 4 }], + [ordre(ECART, EXCEDENT, CUMULEES, HORS, REPETEES, ANIMATEURS), { faute: 'critereAbsent', critere: REDONDANCE }], + [ordre(ECART, EXCEDENT, CUMULEES, HORS, REPETEES, ANIMATEURS, REDONDANCE, EXCEDENT), { faute: 'critereRepete', critere: EXCEDENT, palier: 7 }], + [ordre(ECART, EXCEDENT, CUMULEES, HORS, REPETEES, ANIMATEURS, REDONDANCE, 'constructor'), { faute: 'critereInconnu', critere: 'constructor', palier: 7 }], + [[...DEFAUT.slice(0, 3), [], ...DEFAUT.slice(3)], { faute: 'palierVide', palier: 3 }], + ]; + for (const [paliers, details] of cas) { + const erreur = refuse(() => COMMANDES.get('changerPriorites')(charge, { paliers }), 'PRIORITES_INVALIDES'); + assert.deepEqual(erreur.details, { ...details, remede: null }, JSON.stringify(details)); + } + // Sous le mode par défaut, les tours hors objectif se pondèrent avec + // d'autres. + assert.deepEqual(accepter('changerPriorites', charge, { paliers: HORS_JOINTS }).charge.reglages.priorites, HORS_JOINTS); + }); + + test("sous « chacun » et « minimum », les tours hors objectif joints sont PRIORITES_INVALIDES : la liste se lit sous le mode de l'événement (D23)", () => { + for (const mode of ['chacun', 'minimum']) { + const charge = chargeEtiquetee(); + charge.reglages.modeObjectifs = mode; + const erreur = refuse(() => COMMANDES.get('changerPriorites')(figer(charge), { paliers: HORS_JOINTS }), 'PRIORITES_INVALIDES'); + assert.deepEqual(erreur.details, { faute: 'seulASonPalier', critere: HORS, palier: 1, remede: null }, mode); + // Seuls, ils se déplacent. + const monte = accepter('changerPriorites', charge, { paliers: ordre(ECART, HORS, EXCEDENT, CUMULEES, REPETEES, ANIMATEURS, REDONDANCE) }); + assert.equal(monte.libelle, 'Ordre des priorités : « tours hors objectif » au palier 2 sur 7'); + } + }); + + test('un poids hors des entiers de 1 à 100 est POIDS_HORS_DOMAINE {valeur}, remède null, seul ou dans un palier partagé', () => { + const charge = figer(chargeEtiquetee()); + for (const poids of [0, -1, 101, 2.5, Number.NaN, Infinity]) { + for (const paliers of [ + ordre(ECART, [[EXCEDENT, 2], [CUMULEES, poids]], HORS, REPETEES, ANIMATEURS, REDONDANCE), + ordre(ECART, [[EXCEDENT, poids]], CUMULEES, HORS, REPETEES, ANIMATEURS, REDONDANCE), + ]) { + const erreur = refuse(() => COMMANDES.get('changerPriorites')(charge, { paliers }), 'POIDS_HORS_DOMAINE'); + assert.deepEqual(erreur.details, { valeur: poids, remede: null }, String(poids)); + } + } + for (const poids of [1, 100]) { + const paliers = ordre(ECART, [[EXCEDENT, 2], [CUMULEES, poids]], HORS, REPETEES, ANIMATEURS, REDONDANCE); + assert.deepEqual(accepter('changerPriorites', charge, { paliers }).charge.reglages.priorites, paliers); + } + }); + + test('une liste de forme fausse est une faute du code : TypeError', () => { + const charge = chargeEtiquetee(); + assert.equal(typeof COMMANDES.get('changerPriorites'), 'function'); + for (const paliers of [ + undefined, + null, + 'defaut', + { 0: DEFAUT[0] }, + [...DEFAUT.slice(1), 'redondance'], + [...DEFAUT.slice(1), [null]], + [...DEFAUT.slice(1), [[REDONDANCE, 1]]], + [...DEFAUT.slice(1), [{ critere: 7, poids: 1 }]], + [...DEFAUT.slice(1), [{ critere: ECART, poids: '1' }]], + [...DEFAUT.slice(1), [{ critere: ECART }]], + ]) { + assert.throws(() => COMMANDES.get('changerPriorites')(charge, { paliers }), TypeError, JSON.stringify(paliers)); + } + }); + + test("retablirPriorites retire l'ordre réglé, sans fenêtre (D19) ; sans ordre réglé, SANS_EFFET", () => { + const charge = chargeEtiquetee(); + const regle = accepter('changerPriorites', charge, { paliers: COLLISIONS_JOINTES }).charge; + const retabli = accepter('retablirPriorites', regle, {}); + assert.equal(Object.hasOwn(retabli.charge.reglages, 'priorites'), false); + assert.equal(retabli.libelle, 'Ordre des priorités : ordre par défaut rétabli'); + assert.deepEqual(retabli.avertissements, []); + assert.equal(texteDe(retabli.charge), texteDe(charge)); + refuse(() => COMMANDES.get('retablirPriorites')(charge, {}), 'SANS_EFFET', { commande: 'retablirPriorites', remede: null }); + }); +}); + +describe('mobilier : le mode des objectifs (§ 5.3, § 8.9, A8, A12, D23)', () => { + test("« chacun » et « minimum » s'écrivent ; « places » retire le champ ; le mode courant est SANS_EFFET", () => { + const charge = chargeEtiquetee(); + const chacun = accepter('modifierReglage', charge, { cle: 'modeObjectifs', valeur: 'chacun' }); + assert.equal(chacun.charge.reglages.modeObjectifs, 'chacun'); + assert.equal(chacun.libelle, "Objectifs des tables, chercher d'abord : chacun au moins une fois"); + const minimum = accepter('modifierReglage', chacun.charge, { cle: 'modeObjectifs', valeur: 'minimum' }); + assert.equal(minimum.charge.reglages.modeObjectifs, 'minimum'); + assert.equal(minimum.libelle, "Objectifs des tables, chercher d'abord : le minimum, puis le total"); + const places = accepter('modifierReglage', minimum.charge, { cle: 'modeObjectifs', valeur: 'places' }); + assert.equal(Object.hasOwn(places.charge.reglages, 'modeObjectifs'), false); + assert.equal(places.libelle, "Objectifs des tables, chercher d'abord : le plus de places servies"); + assert.equal(texteDe(places.charge), texteDe(charge)); + refuse(() => COMMANDES.get('modifierReglage')(charge, { cle: 'modeObjectifs', valeur: 'places' }), 'SANS_EFFET', { commande: 'modifierReglage', remede: null }); + refuse(() => COMMANDES.get('modifierReglage')(chacun.charge, { cle: 'modeObjectifs', valeur: 'chacun' }), 'SANS_EFFET'); + }); + + test('une valeur hors des trois modes est une faute du code : TypeError', () => { + const charge = chargeEtiquetee(); + assert.equal(accepter('modifierReglage', charge, { cle: 'modeObjectifs', valeur: 'minimum' }).charge.reglages.modeObjectifs, 'minimum'); + for (const valeur of ['pondere', 'PLACES', 'constructor', null, undefined, true, 1]) { + assert.throws(() => COMMANDES.get('modifierReglage')(charge, { cle: 'modeObjectifs', valeur }), TypeError, String(valeur)); + } + }); + + test('un mode qui compare deux valeurs se refuse quand les tours hors objectif partagent un palier : OBJECTIFS_PALIER_PARTAGE {mode, palier}, le mode et l’ordre inchangés', () => { + const charge = accepter('changerPriorites', chargeEtiquetee(), { paliers: HORS_JOINTS }).charge; + figer(charge); + for (const mode of ['chacun', 'minimum']) { + const erreur = refuse(() => COMMANDES.get('modifierReglage')(charge, { cle: 'modeObjectifs', valeur: mode }), 'OBJECTIFS_PALIER_PARTAGE'); + assert.deepEqual(erreur.details, { mode, palier: 1, remede: null }); + } + assert.equal(modeObjectifs(charge), 'places'); + assert.deepEqual(priorites(charge), HORS_JOINTS); + // Le palier partagé loin du premier : le détail en donne l'index. + const loin = ordre(ECART, EXCEDENT, CUMULEES, REPETEES, [[HORS, 1], [ANIMATEURS, 2]], REDONDANCE); + const chargeLoin = accepter('changerPriorites', chargeEtiquetee(), { paliers: loin }).charge; + figer(chargeLoin); + for (const mode of ['chacun', 'minimum']) { + const erreur = refuse(() => COMMANDES.get('modifierReglage')(chargeLoin, { cle: 'modeObjectifs', valeur: mode }), 'OBJECTIFS_PALIER_PARTAGE'); + assert.deepEqual(erreur.details, { mode, palier: 4, remede: null }); + } + assert.equal(modeObjectifs(chargeLoin), 'places'); + assert.deepEqual(priorites(chargeLoin), loin); + }); + + test("changer de mode ne déplace aucun critère ; un poids écrit d'un critère seul, que le mode ne pondère pas, revient à 1", () => { + const jointe = accepter('changerPriorites', chargeEtiquetee(), { paliers: COLLISIONS_JOINTES }).charge; + const minimum = accepter('modifierReglage', jointe, { cle: 'modeObjectifs', valeur: 'minimum' }).charge; + assert.deepEqual(minimum.reglages.priorites, COLLISIONS_JOINTES); + // Un fichier écrit ailleurs : les tours hors objectif seuls, au poids 7, + // que le mode par défaut admet. + const ailleurs = chargeEtiquetee(); + ailleurs.reglages.priorites = ordre(ECART, [[EXCEDENT, 2], [CUMULEES, 3]], [[HORS, 7]], REPETEES, ANIMATEURS, REDONDANCE); + for (const mode of ['chacun', 'minimum']) { + const rendu = accepter('modifierReglage', ailleurs, { cle: 'modeObjectifs', valeur: mode }).charge; + assert.deepEqual(rendu.reglages.priorites, COLLISIONS_JOINTES, mode); + assert.equal(rendu.reglages.modeObjectifs, mode); + } + const seulAuPoids = chargeEtiquetee(); + seulAuPoids.reglages.priorites = ordre(ECART, EXCEDENT, CUMULEES, [[HORS, 7]], REPETEES, ANIMATEURS, REDONDANCE); + assert.equal(Object.hasOwn(accepter('modifierReglage', seulAuPoids, { cle: 'modeObjectifs', valeur: 'chacun' }).charge.reglages, 'priorites'), false); + }); +}); + +describe("mobilier : l'ordre et le mode ne mettent aucun placement en dérive (D13)", () => { + test('changerPriorites, retablirPriorites et le mode laissent les raisons de dérive telles quelles', () => { + const charge = chargeEtiquetee(); + const listes = [[[1, 2], [3, 4], []], [[1, 3], [2, 4], []], [[1, 4], [2, 3], []]]; + placer(charge, listes, { id: 1 }); + placer(charge, listes, { id: 2, retenu: true }); + const raisons = (apres) => [...charge.propositions, charge.retenu].map((placement) => derive(placement, apres)); + const depart = raisons(charge); + let courante = charge; + for (const [nom, args] of [ + ['changerPriorites', { paliers: HORS_JOINTS }], + ['changerPriorites', { paliers: COLLISIONS_JOINTES }], + ['modifierReglage', { cle: 'modeObjectifs', valeur: 'minimum' }], + ['retablirPriorites', {}], + ['modifierReglage', { cle: 'modeObjectifs', valeur: 'places' }], + ]) { + courante = accepter(nom, courante, args).charge; + assert.deepEqual(raisons(courante), depart, nom); + assert.deepEqual(courante.propositions, charge.propositions, nom); + assert.deepEqual(courante.retenu, charge.retenu, nom); + } + }); +}); diff --git a/src/application/csv.js b/src/application/csv.js index 6182c35..bc56773 100644 --- a/src/application/csv.js +++ b/src/application/csv.js @@ -4,7 +4,9 @@ // Le CSV tel que l'interface l'atteint (§ 13.4) : elle passe par // l'application, jamais par src/csv. Ce module réexporte les fonctions et // les constantes de src/csv dont un écran a besoin, telles quelles — la -// jointure des étiquettes comprise, qu'un écran écrit comme l'export —, et +// jointure des étiquettes comprise, qu'un écran écrit comme l'export, et le +// CSV des objectifs de table, son aperçu, son export et ce que remplacer y +// touche —, et // lie le motif du CSV des refus au libellé français : l'interface ne // choisit pas ce motif, qui est une donnée du fichier écrit, jamais un texte // de la langue témoin. @@ -17,12 +19,15 @@ export { decoder } from '../csv/encodage.js'; export { JOINTURE_ETIQUETTES, exporterParticipants } from '../csv/export.js'; export { bilanRemplacement } from '../csv/import.js'; export { cleNormalisee } from '../csv/normalisation.js'; +export { CHAMPS_OBJECTIFS, apercevoirObjectifs, bilanRemplacementObjectifs, exporterObjectifs } from '../csv/objectifs.js'; /** * Le CSV des refus d'un aperçu (exporterRefus de src/csv), chaque motif - * composé par libelle de son code et de ses détails. + * composé par libelle de son code et de ses détails. L'aperçu est celui des + * participants ou celui des objectifs de table : les deux portent l'en-tête, + * le séparateur et les lignes refusées telles que découpées. * - * @param {import('../csv/apercu.js').Apercu} apercu + * @param {import('../csv/apercu.js').Apercu|import('../csv/objectifs.js').ApercuObjectifs} apercu * @param {Array} [refusees] par défaut ceux de l'aperçu ; après * l'application, resume.refusees * @returns {Uint8Array} diff --git a/src/application/csv.test.js b/src/application/csv.test.js index bd4b975..ea4d412 100644 --- a/src/application/csv.test.js +++ b/src/application/csv.test.js @@ -4,7 +4,9 @@ // Épreuves du relais CSV de l'application (§ 13.4, § 10.1) : l'interface // atteint src/csv par ce module, qui réexporte ses fonctions telles quelles // et lie le motif du CSV des refus au libellé français ; les textes des -// deux refus d'une ligne pour ses étiquettes. Les noms sont inventés. +// deux refus d'une ligne pour ses étiquettes ; le CSV des objectifs de table, +// son CSV des refus et les textes de ses refus (§ 10.4). Les noms sont +// inventés. import assert from 'node:assert/strict'; import { describe, test } from '../../test/lanceur.js'; import * as apercu from '../csv/apercu.js'; @@ -14,19 +16,24 @@ import * as exportCsv from '../csv/export.js'; import * as importCsv from '../csv/import.js'; import { decouper } from '../csv/lecture.js'; import * as normalisation from '../csv/normalisation.js'; +import * as objectifs from '../csv/objectifs.js'; +import { creerCharge } from '../stockage/document.js'; import * as relais from './csv.js'; import { libelle } from './libelles.js'; describe('csv : le relais de src/csv pour l’interface (§ 13.4)', () => { - test('decoder, apercevoir, exporterParticipants, bilanRemplacement, cleNormalisee, CHAMPS et JOINTURE_ETIQUETTES sont ceux de src/csv', () => { + test('decoder, apercevoir, exporterParticipants, bilanRemplacement, cleNormalisee, CHAMPS, JOINTURE_ETIQUETTES et ceux des objectifs sont ceux de src/csv', () => { const attendus = [ ['decoder', encodage.decoder], ['apercevoir', apercu.apercevoir], ['exporterParticipants', exportCsv.exporterParticipants], ['bilanRemplacement', importCsv.bilanRemplacement], ['cleNormalisee', normalisation.cleNormalisee], + ['apercevoirObjectifs', objectifs.apercevoirObjectifs], + ['exporterObjectifs', objectifs.exporterObjectifs], + ['bilanRemplacementObjectifs', objectifs.bilanRemplacementObjectifs], ]; - assert.equal(attendus.length, 5); + assert.equal(attendus.length, 8); for (const [nom, fonction] of attendus) { assert.equal(typeof fonction, 'function', nom); assert.equal(relais[nom], fonction, nom); @@ -34,9 +41,10 @@ describe('csv : le relais de src/csv pour l’interface (§ 13.4)', () => { assert.equal(relais.CHAMPS, colonnes.CHAMPS); assert.equal(relais.JOINTURE_ETIQUETTES, exportCsv.JOINTURE_ETIQUETTES); assert.equal(relais.JOINTURE_ETIQUETTES, ', '); + assert.equal(relais.CHAMPS_OBJECTIFS, objectifs.CHAMPS_OBJECTIFS); assert.deepEqual(Object.keys(relais).sort(), [ - 'CHAMPS', 'JOINTURE_ETIQUETTES', 'apercevoir', 'bilanRemplacement', 'cleNormalisee', 'decoder', - 'exporterParticipants', 'exporterRefus', + 'CHAMPS', 'CHAMPS_OBJECTIFS', 'JOINTURE_ETIQUETTES', 'apercevoir', 'apercevoirObjectifs', 'bilanRemplacement', + 'bilanRemplacementObjectifs', 'cleNormalisee', 'decoder', 'exporterObjectifs', 'exporterParticipants', 'exporterRefus', ]); }); @@ -89,3 +97,81 @@ describe('csv : les textes des refus d’une ligne pour ses étiquettes (§ 10.1 assert.throws(() => libelle('ETIQUETTE_MAL_FORMEE', { ligne: 3, valeur: 'x', raison: 'autre' }), RangeError); }); }); + +// Un événement inventé de quatre tours, deux tables numérotées 1 et 2. +function evenementAuxTables() { + const charge = creerCharge({ id: 'evt-herons', nom: 'Soirée des Hérons', siegesParDefaut: 4, tours: 4 }); + charge.tables = [1, 2].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 200 * id, y: 0 } })); + return charge; +} + +describe('csv : le CSV des objectifs de table par le relais (§ 10.4)', () => { + test("exporterRefus sert tel quel l'aperçu des objectifs : l'en-tête d'origine, ligne, et le motif composé par libelle", () => { + const texte = ['Table;Tour;Objectif;Règle', '1;;finance;', '1;;bénévole;', '2;6;finance;', '2;;finance;peut-être', ''].join('\r\n'); + const lu = relais.apercevoirObjectifs(texte, evenementAuxTables()); + assert.deepEqual(lu.refusees.map(({ ligne, code }) => [ligne, code]), [ + [3, 'OBJECTIF_EN_DOUBLE'], + [4, 'TOUR_HORS_SOIREE'], + [5, 'REGLE_INCONNUE'], + ]); + const [entete, ...lignes] = decouper(relais.decoder(relais.exporterRefus(lu)).texte, ';').enregistrements; + assert.deepEqual(entete, ['Table', 'Tour', 'Objectif', 'Règle', 'ligne', 'motif']); + lu.refusees.forEach(({ code, brut, ...details }, rang) => { + assert.equal(lignes[rang][5], libelle(code, details), code); + assert.deepEqual(lignes[rang].slice(0, 4), brut); + }); + assert.equal(lignes[0][5], "La ligne 2 donne déjà l'objectif de la table 1 pour la soirée : rien n'est fusionné."); + assert.equal(lignes[1][5], "La soirée compte 4 tours : le tour 6 n'en est pas un."); + }); + + test('exporterObjectifs écrit les objectifs des tables, que apercevoirObjectifs relit', () => { + const charge = evenementAuxTables(); + charge.tables[1].objectifs = [{ portee: 'tour', tour: 3, regle: 'et', etiquettes: ['Montréal', 'finance'] }]; + const texte = relais.decoder(relais.exporterObjectifs(charge.tables)).texte; + assert.equal(texte, 'table;tour;objectif;regle\r\n2;3;Montréal, finance;et\r\n'); + assert.deepEqual(relais.apercevoirObjectifs(texte, charge).lignes[0].champs, { + numero: 2, table: 2, tour: 3, regle: 'et', etiquettes: ['Montréal', 'finance'], + }); + assert.deepEqual(relais.bilanRemplacementObjectifs(charge), { objectifs: 1, tables: 1 }); + }); +}); + +describe('csv : les textes des refus et des avis du CSV des objectifs (§ 10.4)', () => { + test('chaque refus de ligne nomme sa valeur ; la règle inconnue redit la liste fermée', () => { + assert.equal(libelle('TABLE_ABSENTE', { ligne: 2, valeur: null }), 'Le numéro de table manque.'); + assert.match(libelle('TABLE_NON_ENTIERE', { ligne: 2, valeur: '3b' }), /^« 3b » n'est pas un numéro de table/); + assert.equal(libelle('NUMERO_TABLE_INCONNU', { ligne: 2, valeur: '9' }), 'Aucune table ne porte le numéro 9.'); + assert.match(libelle('PLUSIEURS_TABLES', { ligne: 2, valeur: '3' }), /Plusieurs tables portent le numéro 3/); + assert.match(libelle('TOUR_NON_ENTIER', { ligne: 2, valeur: 'deux' }), /^« deux » n'est pas un numéro de tour : un entier, ou vide pour la soirée/); + assert.equal(libelle('TOUR_HORS_SOIREE', { ligne: 2, valeur: '0', tours: 1 }), "La soirée compte 1 tour : le tour 0 n'en est pas un."); + assert.equal(libelle('OBJECTIF_VIDE', { ligne: 2, valeur: null }), "L'objectif manque : au moins une étiquette."); + const regle = libelle('REGLE_INCONNUE', { ligne: 2, valeur: 'peut-être' }); + assert.match(regle, /« peut-être »/); + for (const valeur of ['ou', 'une', 'au moins une', 'et', 'toutes', 'vide']) assert.ok(regle.includes(valeur), valeur); + assert.equal( + libelle('OBJECTIF_EN_DOUBLE', { ligne: 8, valeur: null, premiereLigne: 3, numero: 1, tour: 2 }), + "La ligne 3 donne déjà l'objectif de la table 1 au tour 2 : rien n'est fusionné.", + ); + assert.match(libelle('ETIQUETTES_TROP_NOMBREUSES', { ligne: 2, valeur: null, nombre: 21 }), /objectif/); + }); + + test('les refus globaux nomment la colonne qui manque, et le remède d’un événement sans table', () => { + assert.match(libelle('TABLE_NON_ASSOCIEE', {}), /« table »/); + assert.match(libelle('OBJECTIF_NON_ASSOCIE', {}), /« objectif »/); + assert.match(libelle('OBJECTIFS_SANS_TABLE', { ligne: null, remede: { geste: 'ouvrirSection', section: 'tables' } }), /créez les tables/); + assert.doesNotMatch(libelle('AUCUN_ENTETE', {}), /« nom »/); + assert.match(libelle('IMPORT_OBJECTIFS_RECLASSE', {}), /classement/); + }); + + test("le libellé d'historique de l'import nomme le mode et ses comptes ; un mode inconnu lève RangeError", () => { + assert.equal( + libelle('ENTREE_IMPORTER_OBJECTIFS', { mode: 'mettreAJour', ajoutes: 3, remplaces: 1, retires: 0 }), + 'Mis à jour les objectifs de table par import : 3 ajoutés, 1 remplacé', + ); + assert.equal( + libelle('ENTREE_IMPORTER_OBJECTIFS', { mode: 'remplacer', ajoutes: 1, remplaces: 0, retires: 4 }), + 'Remplacé les objectifs de table par import : 4 retirés, 1 ajouté, 0 remplacé', + ); + assert.throws(() => libelle('ENTREE_IMPORTER_OBJECTIFS', { mode: 'ajouter', ajoutes: 1, remplaces: 0, retires: 0 }), RangeError); + }); +}); diff --git a/src/application/evaluation.js b/src/application/evaluation.js index 2fcb024..6156c31 100644 --- a/src/application/evaluation.js +++ b/src/application/evaluation.js @@ -29,8 +29,14 @@ // resumeDuMeilleur en donne l'identifiant à la liste, sur la même table // d'évaluation, diagnostic compris — le premier critère du classement lit // le plafond a priori. +// +// Le classement applique la compilation de l'instance (compilationDe) : son +// ordre des priorités, son mode des objectifs, ses cases. La table porte +// aussi l'état de chaque critère tel que normaliser le donne — coché, sans +// objet, ses valeurs —, que l'écran lit au lieu de le recalculer. import { classer } from '../moteur/classement.js'; import { indexerPlan, normaliser } from '../moteur/configuration.js'; +import { compilationDe } from '../moteur/criteres.js'; import { diagnostiquer } from '../moteur/diagnostic.js'; import { mesurer } from '../moteur/indicateurs.js'; import { ecartsAuPlafondAPriori, minimumAtteint, troisChiffres } from '../moteur/manque.js'; @@ -65,8 +71,13 @@ export { CIBLE_REDONDANCE_PAR_DEFAUT, cibleRedondance }; * @property {LigneEvaluation[]} propositions comparables dans l'ordre du * classement, puis les autres par identifiant * @property {LigneEvaluation|null} retenu - * @property {{ordre: number[], criteresAppliques: string[], critereSaute: string|null}} classement - * classer, tel quel, sur les comparables + * @property {{ordre: number[], criteresAppliques: Array<{criteres: Array<{nom: string, valeurs: string[], poids: number}>}>, + * critereSaute: string|null, rangSaute: number|null}} classement + * classer, tel quel, sur les comparables, sous la compilation de + * l'instance + * @property {Array<{nom: string, coche: boolean, sansObjet: boolean, valeurs: string[]}>} criteres + * l'état de chaque critère, instance.etatCriteres, dans l'ordre du + * registre * @property {{retours: number, ancrages: number}} imposes */ @@ -150,7 +161,10 @@ export function evaluer(charge, { plafondsAPriori = 'calcul' } = {}) { const lignes = charge.propositions.map((proposition) => ligneDe(proposition, proposition.id)); const comparables = lignes.filter(({ statut }) => statut === 'comparable'); - const classement = classer(comparables.map(({ id, mesures }) => ({ id, mesures, plafondsAPriori: aPriori }))); + const classement = classer( + comparables.map(({ id, mesures }) => ({ id, mesures, plafondsAPriori: aPriori })), + compilationDe(instance), + ); const rangDe = new Map(classement.ordre.map((id, rang) => [id, rang])); const autres = lignes.filter(({ statut }) => statut !== 'comparable').sort((a, b) => a.id - b.id); return { @@ -158,6 +172,7 @@ export function evaluer(charge, { plafondsAPriori = 'calcul' } = {}) { propositions: [...comparables.sort((a, b) => rangDe.get(a.id) - rangDe.get(b.id)), ...autres], retenu: charge.retenu === null ? null : ligneDe(charge.retenu, charge.retenu.proposition), classement, + criteres: instance.etatCriteres, imposes: { retours: diagnostic.retoursImposes, ancrages: diagnostic.ancragesImposantRetours }, }; } diff --git a/src/application/evaluation.test.js b/src/application/evaluation.test.js index 6eee041..1f71554 100644 --- a/src/application/evaluation.test.js +++ b/src/application/evaluation.test.js @@ -7,12 +7,25 @@ // partent des démonstrations livrées, et leurs propositions s'enregistrent // par la commande ; les plans construits à la main sont écrits ici, sur la // petite démonstration au conflit inévitable. La valeur attendue vient des -// fonctions du moteur, sollicitées une seconde fois (§ 19.1). +// fonctions du moteur, sollicitées une seconde fois (§ 19.1). Le classement +// suit l'ordre des priorités et le mode de la charge (A11, A21) ; sous +// chacune des seize combinaisons des quatre cases, son ordre est celui de la +// clé de référence privée des critères que les cases décochées retirent, +// relue sur les grandeurs du fichier des empreintes (contrainte 33). import assert from 'node:assert/strict'; +import { + CASES, + CLASSEMENT, + chargeDeClassement, + empreintesDuClassement, + exigerVerification, + lireEmpreintes, +} from '../../test/empreintes_moteur.js'; import { describe, test } from '../../test/lanceur.js'; import { texteLivre } from '../demo/livrees.js'; import { classer } from '../moteur/classement.js'; import { indexerPlan, normaliser } from '../moteur/configuration.js'; +import { compilationDe } from '../moteur/criteres.js'; import { diagnostiquer } from '../moteur/diagnostic.js'; import { ErreurConfiguration } from '../moteur/erreurs.js'; import { mesurer } from '../moteur/indicateurs.js'; @@ -178,7 +191,7 @@ describe("évaluation : la mesure qui fait foi (§ 5.10, § 13.2)", () => { assert.deepEqual([1, 2, 3].map((id) => parId.get(id).ecartAuPlafondAPrioriMax), [3, 3, 3]); assert.ok(parId.get(4).ecartAuPlafondAPrioriMax > 3); assert.deepEqual(table.classement.ordre, [3, 2, 1, 4]); - assert.deepEqual(table.classement.criteresAppliques[0], 'ecartAuPlafondAPrioriMax'); + assert.deepEqual(table.classement.criteresAppliques[0].criteres.map(({ nom }) => nom), ['ecartAuPlafondAPrioriMax']); for (const ligne of table.propositions) { const ecarts = ecartsAuPlafondAPriori(ligne.mesures, plafondsAPriori(normaliser(configurationDe(charge)))); assert.equal(ligne.ecartAuPlafondAPrioriMax, Math.max(...ecarts)); @@ -373,7 +386,7 @@ describe('évaluation : le plafond a priori inconnu (§ 17, point 4)', () => { const charge = figer(generee(chargeDemo('petite-conflit'), { graine: 5, nombre: 2, arret: 300 })); const table = evaluer(charge, { plafondsAPriori: null }); assert.equal(table.classement.critereSaute, 'ecartAuPlafondAPrioriMax'); - assert.equal(table.classement.criteresAppliques[0], 'excedentCollisions'); + assert.deepEqual(table.classement.criteresAppliques[0].criteres.map(({ nom }) => nom), ['excedentCollisions']); for (const ligne of table.propositions) { assert.equal(ligne.troisChiffres.ecartItineraireMax, null); assert.equal(ligne.minimumAtteint, null); @@ -564,3 +577,163 @@ describe('resumeDuMeilleur : la proposition au rang 1 que lit la liste des évé assert.equal(resumeDuMeilleur(charge), null); }); }); + +// --- L'ordre des priorités, le mode et les cases (A11, A21) --------------------- + +exigerVerification(process.env); + +// La petite démonstration au conflit inévitable, où 1 à 6 portent +// « finance » et la table d'identifiant le plus petit l'objectif « finance » +// pour la soirée ; le mode et l'ordre des priorités donnés, quand ils le +// sont. +function chargeAObjectifs({ mode, priorites } = {}) { + 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'] }]; + if (mode !== undefined) charge.reglages.modeObjectifs = mode; + if (priorites !== undefined) charge.reglages.priorites = priorites; + return charge; +} + +const e = (critere, poids = 1) => ({ critere, poids }); + +describe("évaluation : l'état des critères et les mesures nouvelles (contrat du classement)", () => { + test("criteres porte l'état de chaque critère tel que normaliser le donne, valeurs comprises", () => { + const charge = figer(generee(chargeAObjectifs({ mode: 'chacun' }), { graine: 3, nombre: 2, arret: 300 })); + const table = evaluer(charge); + const instance = normaliser(configurationDe(charge)); + assert.deepEqual(table.criteres, instance.etatCriteres); + assert.deepEqual(table.criteres.find(({ nom }) => nom === 'toursHorsObjectif'), { + nom: 'toursHorsObjectif', + coche: true, + sansObjet: false, + valeurs: ['jamaisServies', 'toursHorsObjectif'], + }); + assert.deepEqual(table.criteres.find(({ nom }) => nom === 'animateursNonRencontres').coche, false); + for (const ligne of table.propositions) { + assert.equal(ligne.mesures.objectifs.concernes, 6, `${ligne.id}`); + assert.equal(ligne.mesures.animateurs, null, `${ligne.id}`); + } + }); + + test('sans objectif, les objectifs sont sans objet, et les lignes portent des mesures nouvelles nulles', () => { + const table = evaluer(figer(generee(chargeDemo('petite'), { graine: 2, nombre: 1, arret: 100 }))); + assert.equal(table.criteres.find(({ nom }) => nom === 'toursHorsObjectif').sansObjet, true); + assert.deepEqual(table.propositions.map(({ mesures }) => [mesures.objectifs, mesures.animateurs]), [[null, null]]); + }); +}); + +describe("évaluation : le classement suit l'ordre et le mode de la charge (A11, A21)", () => { + test("sous minimum et un ordre réglé, le classement est celui de classer sur la compilation de l'instance", () => { + const priorites = [ + [e('ecartAuPlafondAPrioriMax')], + [e('toursHorsObjectif')], + [e('excedentCollisions'), e('redondance', 3)], + [e('collisionsCumulees')], + [e('rencontresRepetees')], + [e('animateursNonRencontres')], + ]; + const charge = figer(generee(chargeAObjectifs({ mode: 'minimum', priorites }), { graine: 4, nombre: 6, arret: 200 })); + const table = evaluer(charge); + const instance = normaliser(configurationDe(charge)); + const aPriori = plafondsAPriori(instance); + const entrees = charge.propositions.map(({ id }) => ({ + id, + mesures: table.propositions.find((ligne) => ligne.id === id).mesures, + plafondsAPriori: aPriori, + })); + assert.deepEqual(table.classement, classer(entrees, compilationDe(instance))); + assert.deepEqual( + table.classement.criteresAppliques.map(({ criteres }) => criteres.map(({ nom, valeurs, poids }) => `${nom}:${valeurs.join('+')}×${poids}`)), + [ + ['ecartAuPlafondAPrioriMax:ecartAuPlafondAPrioriMax×1'], + ['toursHorsObjectif:horsObjectifMax+toursHorsObjectif×1'], + ['excedentCollisions:excedentCollisions×1', 'redondance:redondance×3'], + ['collisionsCumulees:collisionsCumulees×1'], + ['rencontresRepetees:rencontresRepeteesChoisies×1'], + ], + ); + assert.notDeepEqual(table.classement.criteresAppliques, classer(entrees).criteresAppliques); + }); + + test('sans objectif, les trois modes rendent le même classement que la charge sans mode', () => { + const charge = generee(chargeDemo('petite-conflit'), { graine: 6, nombre: 5, arret: 200 }); + const reference = evaluer(charge).classement; + for (const mode of ['chacun', 'minimum']) { + const autre = structuredClone(charge); + autre.reglages.modeObjectifs = mode; + assert.deepEqual(evaluer(autre).classement, reference, mode); + } + }); +}); + +describe('évaluation : sous les seize combinaisons des cases, la clé de référence privée des critères retirés (A21, contrainte 33)', () => { + const REFERENCE = lireEmpreintes(); + // La clé de référence privée des critères que les cases décochées + // retirent, en grandeurs nommées du fichier : l'écart toujours ; + // l'excédent et les cumulées sous « séparer » ; les rencontres répétées + // choisies sous « nouveaux voisins », sinon les retours choisis sous + // « nouvelle table » seule ; la redondance sous « varier ». + const cleSous = (cases) => [ + 'ecartAuPlafondAPrioriMax', + ...(cases.separerAppartenances ? ['excedentCollisions', 'collisionsCumulees'] : []), + ...(cases.nouveauxVoisins ? ['rencontresRepeteesChoisies'] : cases.nouvelleTable ? ['retoursChoisis'] : []), + ...(cases.varierAppartenances ? ['redondance'] : []), + ]; + // Le nom du critère appliqué qui classe chaque grandeur de la clé. + const CRITERE_DE = { + ecartAuPlafondAPrioriMax: 'ecartAuPlafondAPrioriMax', + excedentCollisions: 'excedentCollisions', + collisionsCumulees: 'collisionsCumulees', + rencontresRepeteesChoisies: 'rencontresRepetees', + retoursChoisis: 'rencontresRepetees', + redondance: 'redondance', + }; + // Les identifiants rangés grandeur par grandeur de la clé, puis par + // identifiant croissant. + const ordonner = (grandeurs, cle, ids) => [...ids].sort((a, b) => { + for (const nom of cle) { + const ecart = grandeurs[String(a)][nom] - grandeurs[String(b)][nom]; + if (ecart !== 0) return ecart; + } + return a - b; + }); + + for (const cle of CLASSEMENT.cles) { + test(`${cle} : chaque combinaison classe comme sa clé privée, et ne nomme que ses critères ; l'ordre change sous une combinaison au moins`, () => { + const charge = chargeDeClassement(cle); + const { propositions: grandeurs } = REFERENCE.classements[cle]; + const ids = charge.propositions.map(({ id }) => id); + let differentes = 0; + for (let masque = 0; masque < 16; masque += 1) { + const cases = Object.fromEntries(CASES.map((nom, bit) => [nom, (masque & (1 << bit)) !== 0])); + const sous = structuredClone(charge); + Object.assign(sous.reglages, cases); + const table = evaluer(sous); + const nom = `${cle}, ${CASES.filter((c) => cases[c]).join('+') || 'aucune'}`; + assert.ok(table.propositions.every(({ statut }) => statut === 'comparable'), nom); + const attendu = ordonner(grandeurs, cleSous(cases), ids); + assert.deepEqual(table.classement.ordre, attendu, nom); + assert.deepEqual( + table.classement.criteresAppliques.map(({ criteres }) => criteres.map(({ nom: n, valeurs }) => `${n}:${valeurs.join('+')}`).join()), + cleSous(cases).map((g) => `${CRITERE_DE[g]}:${g}`), + nom, + ); + if (attendu.join() !== REFERENCE.classements[cle].connu.ordre.join()) differentes += 1; + } + assert.ok(differentes > 0, cle); + }); + } +}); + +describe('évaluation : le mode ne touche pas au classement de référence, sans objectif (A21)', () => { + const REFERENCE = lireEmpreintes(); + for (const mode of ['chacun', 'minimum']) { + test(`${mode} : ordre, critères appliqués et grandeurs, plafond a priori connu puis inconnu, ceux des empreintes de référence`, () => { + for (const cle of CLASSEMENT.cles) { + assert.deepEqual(empreintesDuClassement(cle, mode), REFERENCE.classements[cle], `${cle}, ${mode}`); + } + }); + } +}); diff --git a/src/application/generation.test.js b/src/application/generation.test.js index 47bd836..99e95ae 100644 --- a/src/application/generation.test.js +++ b/src/application/generation.test.js @@ -1483,3 +1483,66 @@ describe('génération : une table, un tour (§ 15.4)', () => { assert.deepEqual(planDepuisFichier(montage.charge().propositions[0]).tours, [[[1, 2, 3, 4, 5, 6]]]); }); }); + +// --- Objectifs, mode et ordre réglé (§ 5.7, D17, A21) ---------------------------------- + +describe('génération : objectifs, mode et ordre réglé, par la séance (§ 5.7, D17, A21)', () => { + // La petite démonstration au conflit inévitable, 1 à 6 « finance », la + // première table attendant « finance » pour la soirée ; le mode minimum + // par défaut (sous places, aucun mode écrit) et, sous ordre, un ordre où + // les tours hors objectif suivent l'écart et où l'excédent et la + // redondance partagent un palier. + function chargeAObjectifs({ mode = 'minimum', ordre = true } = {}) { + const charge = 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'] }]; + if (mode !== 'places') charge.reglages.modeObjectifs = mode; + if (ordre) { + const e = (critere, poids = 1) => ({ critere, poids }); + charge.reglages.priorites = [ + [e('ecartAuPlafondAPrioriMax')], + [e('toursHorsObjectif')], + [e('excedentCollisions', 2), e('redondance')], + [e('collisionsCumulees')], + [e('rencontresRepetees')], + [e('animateursNonRencontres')], + ]; + } + return charge; + } + + test('fil direct, puis groupe de travailleurs à un et à quatre cœurs : le même état écrit, octet pour octet, et les plans de rechercher', async () => { + const reglages = { graine: 3, nombre: 4, arret: 1500, historique: 1000 }; + const executeurs = [ + ['fil direct', () => creerExecuteurDirect(), null], + ['un cœur', () => creerExecuteurTravailleur(travailleursCalculants(), { coeursDisponibles: 4 }), 1], + ['quatre cœurs', () => creerExecuteurTravailleur(travailleursCalculants(), { coeursDisponibles: 4 }), 4], + ]; + const etats = []; + for (const [nom, creer, coeurs] of executeurs) { + const montage = await monter({ charges: [chargeAObjectifs()], executeur: creer() }); + if (coeurs !== null) { + montage.controleur.reglerCoeurs({ automatique: false }); + montage.controleur.reglerCoeurs({ saisie: String(coeurs) }); + } + const bilan = await montage.controleur.generer(reglages); + assert.deepEqual(bilan.ajoutees, [1, 2, 3, 4], nom); + const configuration = configurationDe(montage.charge()); + assert.equal(configuration.modeObjectifs, 'minimum', nom); + const attendues = rechercher(configuration, reglages); + assert.deepEqual(montage.charge().propositions.map(planDepuisFichier), attendues.map((p) => planRange(p, configuration)), nom); + etats.push(montage.etatDisque()); + } + assert.equal(etats[1], etats[0]); + assert.equal(etats[2], etats[0]); + }); + + test('le mode et l’ordre atteignent la recherche, chacun pour sa part : sans le mode, sous le même ordre, ou sans l’ordre, sous le même mode, la même charge rend d’autres plans', () => { + const reglages = { graine: 3, nombre: 4, arret: 1500 }; + const plans = (charge) => rechercher(configurationDe(charge), reglages).map(({ plan }) => plan); + const regles = plans(chargeAObjectifs()); + assert.notDeepEqual(regles, plans(chargeAObjectifs({ mode: 'places' })), 'sans le mode'); + assert.notDeepEqual(regles, plans(chargeAObjectifs({ ordre: false })), 'sans l’ordre'); + }); +}); diff --git a/src/application/libelles.test.js b/src/application/libelles.test.js index c382fba..63794ff 100644 --- a/src/application/libelles.test.js +++ b/src/application/libelles.test.js @@ -30,6 +30,7 @@ import { join, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, test } from '../../test/lanceur.js'; import { CRITERES } from '../moteur/classement.js'; +import { CRITERES as CRITERES_DU_REGISTRE, MODES_OBJECTIFS, valeursQuiClassent } from '../moteur/criteres.js'; import { TABLES_INTERFACE } from '../interface/libelles.js'; import { creerTraduction } from './traduction.js'; import { CODES, TABLES_APPLICATION, libelle } from './libelles.js'; @@ -345,6 +346,21 @@ const EXEMPLES = new Map([ ['DEJA_DESIGNE', { ligne: 9, valeur: null, participants: [3], premiereLigne: 4 }], ['PLAN_BLOQUE', { remede: { geste: 'debloquer' } }], ['AMBIGUITE', { champs: ['appartenance', 'courriel'] }], + // Le CSV des objectifs de table (§ 10.4) + ['TABLE_NON_ASSOCIEE', {}], + ['OBJECTIF_NON_ASSOCIE', {}], + ['OBJECTIFS_SANS_TABLE', { ligne: null, remede: { geste: 'ouvrirSection', section: 'tables' } }], + ['TABLE_ABSENTE', { ligne: 3, valeur: null }], + ['TABLE_NON_ENTIERE', { ligne: 3, valeur: '3b' }], + ['NUMERO_TABLE_INCONNU', { ligne: 4, valeur: '9' }], + ['PLUSIEURS_TABLES', { ligne: 4, valeur: '3' }], + ['TOUR_NON_ENTIER', { ligne: 5, valeur: 'deux' }], + ['TOUR_HORS_SOIREE', { ligne: 5, valeur: '9', tours: 4 }], + ['OBJECTIF_VIDE', { ligne: 6, valeur: null }], + ['REGLE_INCONNUE', { ligne: 6, valeur: 'peut-être' }], + ['OBJECTIF_EN_DOUBLE', { ligne: 8, valeur: null, premiereLigne: 3, numero: 1, tour: 2 }], + ['IMPORT_OBJECTIFS_RECLASSE', {}], + ['ENTREE_IMPORTER_OBJECTIFS', { mode: 'mettreAJour', ajoutes: 3, remplaces: 1, retires: 0 }], // Séance ['AUCUN_EVENEMENT', {}], ['MODE_LECTURE', {}], @@ -566,6 +582,12 @@ const EXEMPLES = new Map([ ['ENTREE_POSER_OBJECTIF', { numero: 3, tour: 2, regle: 'et', etiquettes: ['Montréal', 'finance'] }], ['ENTREE_RETIRER_OBJECTIF', { numero: 3, tour: null }], ['ENTREE_CHANGER_REGLE_OBJECTIFS', { regle: 'et' }], + // Ordre des priorités et mode des objectifs + ['PRIORITES_INVALIDES', { faute: 'seulASonPalier', critere: 'ecartAuPlafondAPrioriMax', palier: 0, remede: null }], + ['POIDS_HORS_DOMAINE', { valeur: 0, remede: null }], + ['OBJECTIFS_PALIER_PARTAGE', { mode: 'chacun', palier: 1, remede: null }], + ['ENTREE_CHANGER_PRIORITES', { criteres: ['collisionsCumulees'], palier: 1, paliers: 6, avec: ['excedentCollisions'], poids: null }], + ['ENTREE_RETABLIR_PRIORITES', {}], // Planche de badges ['PLANCHE_SANS_PLACEMENT', { remede: { geste: 'ouvrirSection', section: 'propositions' } }], ['PROPOSITION_FAUTIVE', { id: 2, remede: { geste: 'ouvrirSection', section: 'propositions' } }], @@ -967,14 +989,44 @@ describe('libellés : les codes du moteur et de la génération (§ 14.6, § 5.7 for (const code of ['PLACES_MANQUANTES', 'SURRESERVATION']) assert.ok(MOTEUR_TABLE.has(code), code); }); - test('chaque critère de CRITERES a son libellé moteur.critere.', () => { - assert.ok(CRITERES.length > 0); - for (const critere of CRITERES) { + test('chaque critère du registre a son libellé moteur.critere., ceux de l’ordre fixe de référence compris', () => { + assert.ok(CRITERES.length > 0 && CRITERES.every((critere) => CRITERES_DU_REGISTRE.includes(critere))); + for (const critere of CRITERES_DU_REGISTRE) { const texte = libelle(`moteur.critere.${critere}`); assert.ok(texte.length > 0, critere); for (const trace of TRACES) assert.doesNotMatch(texte, trace); } - assert.equal(new Set(CRITERES.map((critere) => libelle(`moteur.critere.${critere}`))).size, CRITERES.length); + const noms = CRITERES_DU_REGISTRE.map((critere) => libelle(`moteur.critere.${critere}`)); + assert.equal(new Set(noms).size, CRITERES_DU_REGISTRE.length); + }); + + test('chaque valeur qui classe, sous toute case et tout mode, a son libellé moteur.valeur. ; la phrase de l’ordre fixe y garde ses noms', () => { + const valeurs = new Set(); + for (let masque = 0; masque < 4; masque += 1) { + const contraintes = { nouveauxVoisins: (masque & 1) === 1, nouvelleTable: (masque & 2) === 2 }; + for (const mode of MODES_OBJECTIFS) { + for (const critere of CRITERES_DU_REGISTRE) for (const valeur of valeursQuiClassent(critere, contraintes, mode)) valeurs.add(valeur); + } + } + assert.equal(valeurs.size, 10); + const textes = [...valeurs].map((valeur) => libelle(`moteur.valeur.${valeur}`)); + for (const texte of textes) { + assert.ok(texte.length > 0); + for (const trace of TRACES) assert.doesNotMatch(texte, trace); + } + assert.equal(new Set(textes).size, valeurs.size); + // Sous le défaut, la valeur qui classe porte le nom de son critère. + for (const critere of CRITERES_DU_REGISTRE) { + const [premiere] = valeursQuiClassent(critere, { nouveauxVoisins: true, nouvelleTable: true }); + assert.equal(libelle(`moteur.valeur.${premiere}`), libelle(`moteur.critere.${critere}`), critere); + } + assert.equal(libelle('moteur.valeur.retoursChoisis'), 'retours'); + assert.equal(libelle('moteur.valeur.jamaisServies'), 'personnes jamais servies'); + }); + + test('un palier partagé se lit « ensemble », chaque critère suivi de son poids', () => { + assert.equal(libelle('moteur.palier.ensemble'), 'ensemble'); + assert.equal(libelle('moteur.palier.poids', { poids: 3 }), 'poids 3'); }); test("les tables de l'application et des écrans s'assemblent sans clé en double", () => { @@ -1129,3 +1181,72 @@ describe('libellés : les étiquettes et les objectifs des tables (§ 4.5, § 5. ); }); }); + +describe("libellés : l'ordre des priorités et le mode des objectifs (§ 5.7, § 5.9, § 8.2, A11, A12, D22)", () => { + test('chaque critère du registre a son nom complet mobilier.critere., deux à deux distincts', () => { + assert.equal(CRITERES_DU_REGISTRE.length, 7); + const noms = CRITERES_DU_REGISTRE.map((critere) => libelle(`mobilier.critere.${critere}`)); + for (const nom of noms) { + assert.ok(nom.length > 0); + for (const trace of TRACES) assert.doesNotMatch(nom, trace); + } + assert.equal(new Set(noms).size, noms.length); + assert.equal(libelle('mobilier.critere.rencontresRepetees'), 'rencontres répétées et retours'); + }); + + test('le libellé figé de changerPriorites nomme le critère, son palier sur le nombre de paliers, et ceux qu’il rejoint ou son poids', () => { + assert.equal( + libelle('ENTREE_CHANGER_PRIORITES', { criteres: ['collisionsCumulees'], palier: 1, paliers: 6, avec: ['excedentCollisions'], poids: null }), + 'Ordre des priorités : « collisions cumulées » au palier 2 sur 6, avec « excédent de collisions »', + ); + assert.equal( + libelle('ENTREE_CHANGER_PRIORITES', { criteres: ['toursHorsObjectif'], palier: 3, paliers: 7, avec: [], poids: null }), + 'Ordre des priorités : « tours hors objectif » au palier 4 sur 7', + ); + assert.equal( + libelle('ENTREE_CHANGER_PRIORITES', { criteres: ['excedentCollisions', 'collisionsCumulees'], palier: 3, paliers: 6, avec: [], poids: null }), + 'Ordre des priorités : « excédent de collisions » et « collisions cumulées » au palier 4 sur 6', + ); + assert.equal( + libelle('ENTREE_CHANGER_PRIORITES', { criteres: ['collisionsCumulees'], palier: 1, paliers: 6, avec: [], poids: 5 }), + 'Ordre des priorités : « collisions cumulées » au poids 5, palier 2 sur 6', + ); + assert.equal(libelle('ENTREE_RETABLIR_PRIORITES'), 'Ordre des priorités : ordre par défaut rétabli'); + assert.throws(() => libelle('ENTREE_CHANGER_PRIORITES', { criteres: ['constructor'], palier: 0, paliers: 7, avec: [], poids: null }), RangeError); + }); + + test("le mode des objectifs se nomme au journal par ce qu'il cherche d'abord, sans le mot que l'écran seul porte (D22) ; un mode inconnu lève", () => { + const attendus = [ + ['places', "Objectifs des tables, chercher d'abord : le plus de places servies"], + ['chacun', "Objectifs des tables, chercher d'abord : chacun au moins une fois"], + ['minimum', "Objectifs des tables, chercher d'abord : le minimum, puis le total"], + ]; + for (const [valeur, texte] of attendus) { + assert.equal(libelle('ENTREE_MODIFIER_REGLAGE', { cle: 'modeObjectifs', valeur }), texte); + assert.doesNotMatch(texte, /[ée]quit[ée]/i); + } + assert.throws(() => libelle('ENTREE_MODIFIER_REGLAGE', { cle: 'modeObjectifs', valeur: 'pondere' }), RangeError); + assert.throws(() => libelle('OBJECTIFS_PALIER_PARTAGE', { mode: 'pondere', palier: 1, remede: null }), RangeError); + }); + + test('les refus nomment le palier à partir de 1, le critère par son nom complet, et la cause seule ; une faute inconnue lève', () => { + assert.equal( + libelle('PRIORITES_INVALIDES', { faute: 'seulASonPalier', critere: 'ecartAuPlafondAPrioriMax', palier: 0, remede: null }), + '« écart au plafond a priori » se range seul à son palier : le palier 1 ne peut pas le partager avec un autre critère.', + ); + assert.match(libelle('PRIORITES_INVALIDES', { faute: 'critereAbsent', critere: 'redondance', remede: null }), /omet « redondance d'appartenance »/); + assert.match(libelle('PRIORITES_INVALIDES', { faute: 'critereRepete', critere: 'excedentCollisions', palier: 7, remede: null }), /« excédent de collisions » deux fois/); + assert.match(libelle('PRIORITES_INVALIDES', { faute: 'critereInconnu', critere: 'constructor', palier: 7, remede: null }), /inconnu, « constructor »/); + assert.match(libelle('PRIORITES_INVALIDES', { faute: 'palierVide', palier: 3, remede: null }), /palier 4\b/); + assert.match(libelle('PRIORITES_INVALIDES', { faute: 'forme', remede: null }), /liste de paliers/); + assert.match(libelle('PRIORITES_INVALIDES', { faute: 'poids', critere: 'toursHorsObjectif', palier: 2, remede: null }), /« tours hors objectif » au palier 3/); + assert.throws(() => libelle('PRIORITES_INVALIDES', { faute: 'inventee', remede: null }), RangeError); + assert.equal(libelle('POIDS_HORS_DOMAINE', { valeur: 0, remede: null }), "Un poids est un nombre entier de 1 à 100 : 0 ne l'est pas."); + assert.equal(libelle('POIDS_HORS_DOMAINE', { valeur: 2.5, remede: null }), "Un poids est un nombre entier de 1 à 100 : 2,5 ne l'est pas."); + assert.equal(libelle('POIDS_HORS_DOMAINE', { valeur: Number.NaN, remede: null }), 'Un poids est un nombre entier de 1 à 100.'); + 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/]) { + assert.match(partage, motif); + } + }); +}); diff --git a/src/application/libelles/csv.js b/src/application/libelles/csv.js index c01eaf9..a42bda2 100644 --- a/src/application/libelles/csv.js +++ b/src/application/libelles/csv.js @@ -1,10 +1,11 @@ // © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -// Les textes des codes de src/csv (§ 10.1, § 14.6) : encodage, séparateur, -// en-tête, refus d'une ligne, refus de la mise à jour. +// Les textes des codes de src/csv (§ 10.1, § 10.4, § 14.6) : encodage, +// séparateur, en-tête, refus d'une ligne, refus de la mise à jour, et ceux du +// CSV des objectifs de table, son import et son libellé d'historique. import { ETIQUETTE_LONGUEUR_MAX, ETIQUETTES_MAX } from '../../stockage/etiquettes.js'; -import { cite, enumeration, nomDans } from './aides.js'; +import { cite, compte, enumeration, nomDans } from './aides.js'; // Le remède commun aux refus d'encodage (§ 10.1). const REMEDE_UTF8 = 'Réenregistrez le fichier en UTF-8 depuis le tableur, puis importez-le de nouveau.'; @@ -40,7 +41,7 @@ export const TABLE = new Map([ `Un guillemet ouvert${Number.isInteger(ligne) ? ` à la ligne ${ligne}` : ''} ne se referme pas : ` + 'tout ce qui le suit tiendrait dans un seul champ.', ], - ['AUCUN_ENTETE', () => "Aucun en-tête n'est reconnu : la première ligne nomme les colonnes, « nom » au moins."], + ['AUCUN_ENTETE', () => "Aucun en-tête n'est reconnu : la première ligne nomme les colonnes du fichier."], ['NOM_NON_ASSOCIE', () => "Aucune colonne ne porte le nom : l'en-tête doit compter une colonne « nom »."], ['AUCUNE_LIGNE_VALIDE', () => "Aucune ligne n'est valide : rien n'est importé."], ['CHAMPS_EN_TROP', () => "La ligne compte plus de champs que l'en-tête."], @@ -59,7 +60,7 @@ export const TABLE = new Map([ ], [ 'ETIQUETTES_TROP_NOMBREUSES', - ({ nombre }) => `La ligne porte ${nombre} étiquettes : une personne en porte ${ETIQUETTES_MAX} au plus.`, + ({ nombre }) => `La ligne porte ${nombre} étiquettes : une personne ou un objectif en porte ${ETIQUETTES_MAX} au plus.`, ], [ 'PLUSIEURS_CORRESPONDENT', @@ -77,4 +78,53 @@ export const TABLE = new Map([ ({ champs }) => `Plusieurs colonnes répondent au même champ (${enumeration(champs)}) : choisissez celle qui s'importe.`, ], + // --- Le CSV des objectifs de table (§ 10.4) ------------------------------- + ['TABLE_NON_ASSOCIEE', () => "Aucune colonne ne porte le numéro de table : l'en-tête doit compter une colonne « table »."], + ['OBJECTIF_NON_ASSOCIE', () => "Aucune colonne ne porte l'objectif : l'en-tête doit compter une colonne « objectif »."], + ['OBJECTIFS_SANS_TABLE', () => "L'événement n'a aucune table : créez les tables avant d'importer leurs objectifs."], + ['TABLE_ABSENTE', () => 'Le numéro de table manque.'], + [ + 'TABLE_NON_ENTIERE', + ({ valeur }) => `${cite(valeur)} n'est pas un numéro de table : un entier, celui que la table affiche sur le plan.`, + ], + ['NUMERO_TABLE_INCONNU', ({ valeur }) => `Aucune table ne porte le numéro ${valeur}.`], + [ + 'PLUSIEURS_TABLES', + ({ valeur }) => `Plusieurs tables portent le numéro ${valeur} : la ligne ne dit pas laquelle elle vise.`, + ], + ['TOUR_NON_ENTIER', ({ valeur }) => `${cite(valeur)} n'est pas un numéro de tour : un entier, ou vide pour la soirée.`], + [ + 'TOUR_HORS_SOIREE', + ({ valeur, tours }) => `La soirée compte ${compte(tours, 'tour', 'tours')} : le tour ${valeur} n'en est pas un.`, + ], + ['OBJECTIF_VIDE', () => "L'objectif manque : au moins une étiquette."], + [ + 'REGLE_INCONNUE', + ({ valeur }) => + `La règle ${cite(valeur)} n'est pas reconnue : ou, une, au moins une ; et, toutes ; ` + + 'vide pour suivre la règle par défaut.', + ], + [ + 'OBJECTIF_EN_DOUBLE', + ({ premiereLigne, numero, tour }) => + `La ligne ${premiereLigne} donne déjà l'objectif de la table ${numero} ` + + `${tour === null ? 'pour la soirée' : `au tour ${tour}`} : rien n'est fusionné.`, + ], + [ + 'IMPORT_OBJECTIFS_RECLASSE', + () => + 'Le plan est retenu : les objectifs importés peuvent changer le classement des propositions, ' + + "sans qu'aucune passe en dérive.", + ], + [ + 'ENTREE_IMPORTER_OBJECTIFS', + ({ mode, ajoutes, remplaces, retires }) => { + const ajoutesEtRemplaces = `${compte(ajoutes, 'ajouté', 'ajoutés')}, ${compte(remplaces, 'remplacé', 'remplacés')}`; + if (mode === 'mettreAJour') return `Mis à jour les objectifs de table par import : ${ajoutesEtRemplaces}`; + if (mode === 'remplacer') { + return `Remplacé les objectifs de table par import : ${compte(retires, 'retiré', 'retirés')}, ${ajoutesEtRemplaces}`; + } + throw new RangeError(`libelle : mode d'import des objectifs inconnu ${JSON.stringify(mode)}`); + }, + ], ]); diff --git a/src/application/libelles/mobilier.js b/src/application/libelles/mobilier.js index f3234cb..a1f6d10 100644 --- a/src/application/libelles/mobilier.js +++ b/src/application/libelles/mobilier.js @@ -2,13 +2,16 @@ // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // Les textes des commandes du mobilier — tables, sièges, tours, réglages, -// objectifs des tables (§ 4, § 4.5, § 6, § 7.1, § 8.2, § 14.6) : les refus -// d'une capacité, d'un nombre de tours et d'un objectif, leurs -// avertissements, les causes d'un siège retiré, les avis des objectifs et -// les libellés figés du journal. Les détails désignent tables et personnes par +// objectifs des tables, ordre des priorités (§ 4, § 4.5, § 5.7, § 6, § 7.1, +// § 8.2, § 14.6) : les refus d'une capacité, d'un nombre de tours, d'un +// objectif, d'un ordre des priorités et d'un mode des objectifs, leurs +// avertissements, les causes d'un siège retiré, les avis des objectifs, le +// nom complet de chaque critère (mobilier.critere.) et les libellés +// figés du journal. Les détails désignent tables et personnes par // identifiant : le refus se pose sur la table qu'il concerne, et son texte ne // les nomme pas. Un libellé figé reçoit le numéro de table que la commande // lit dans la charge. +import { POIDS_MAX } from '../../moteur/criteres.js'; import { cite, compte, enumeration, nomDans } from './aides.js'; // Le nom de chaque réglage que modifierReglage change, tel que le journal le @@ -24,6 +27,51 @@ const NOMS_DES_REGLAGES = new Map([ ['generation.historique', "Historique d'acceptation"], ['respecterObjectifs', 'Respecter les objectifs des tables'], ['rencontrerAnimateurs', "Rencontrer le plus d'animateurs différents"], + ['modeObjectifs', "Objectifs des tables, chercher d'abord"], +]); + +// Ce que chaque mode des objectifs cherche d'abord (§ 5.3, A8). Le troisième +// se nomme par ce qu'il compare : le libellé que l'écran lui donne est le +// seul emploi admis de son mot (D22). +const NOMS_DES_MODES = new Map([ + ['places', 'le plus de places servies'], + ['chacun', 'chacun au moins une fois'], + ['minimum', 'le minimum, puis le total'], +]); +const nomMode = (mode) => nomDans(NOMS_DES_MODES, mode, 'mode des objectifs'); + +// Le nom complet d'un critère, cité, que lit sa clé mobilier.critere.. +const nomCritere = (critere, texte) => cite(texte(`mobilier.critere.${critere}`)); + +// Un palier, compté à partir de 1 pour l'affichage ; les détails le +// portent à partir de 0, comme verifierPriorites. +const numeroDePalier = (palier) => palier + 1; + +// Les fautes d'un ordre des priorités (verifierPriorites), chacune sa +// phrase : la cause seule, le critère nommé quand la faute le désigne. +const FAUTES_DE_PRIORITES = new Map([ + ['forme', () => "L'ordre des priorités n'a pas la forme d'une liste de paliers."], + ['critereInconnu', ({ critere }) => `L'ordre des priorités nomme un critère inconnu, ${cite(String(critere))}.`], + [ + 'critereRepete', + ({ critere }, texte) => `L'ordre des priorités nomme ${nomCritere(critere, texte)} deux fois : chaque critère y figure une seule fois.`, + ], + [ + 'critereAbsent', + ({ critere }, texte) => `L'ordre des priorités omet ${nomCritere(critere, texte)} : chaque critère y figure une fois, coché ou non.`, + ], + ['palierVide', ({ palier }) => `Le palier ${numeroDePalier(palier)} de l'ordre des priorités ne porte aucun critère.`], + [ + 'seulASonPalier', + ({ critere, palier }, texte) => + `${nomCritere(critere, texte)} se range seul à son palier : le palier ${numeroDePalier(palier)} ` + + 'ne peut pas le partager avec un autre critère.', + ], + [ + 'poids', + ({ critere, palier }, texte) => + `Le poids de ${nomCritere(critere, texte)} au palier ${numeroDePalier(palier)} sort des entiers de 1 à ${POIDS_MAX}.`, + ], ]); // La règle d'un objectif (§ 4.5) : celle de l'événement quand elle est @@ -45,6 +93,9 @@ const aCeTour = (tour) => (tour === null ? 'pour la soirée' : `au tour ${tour}` // interrupteur, le nombre sinon. const valeurDeReglage = (valeur) => (typeof valeur === 'boolean' ? (valeur ? 'activé' : 'désactivé') : String(valeur)); +// Un nombre écrit à la française : la virgule décimale. +const nombreEcrit = (valeur) => String(valeur).replace('.', ','); + // Ce qu'une saisie refusée est : vide, ou un texte qui n'est pas un entier. const saisieCitee = (saisie) => (String(saisie).trim() === '' ? 'La saisie est vide' : `${cite(String(saisie))} n'est pas un nombre entier`); @@ -134,6 +185,31 @@ export const TABLE = new Map([ (voisine === null || voisine === undefined ? '.' : ` ; une personne porte ${cite(voisine)}.`), ], + // --- L'ordre des priorités et le mode des objectifs (§ 5.7, § 5.9, A12) --- + ['PRIORITES_INVALIDES', (details, texte) => nomDans(FAUTES_DE_PRIORITES, details.faute, 'faute de priorités')(details, texte)], + [ + 'POIDS_HORS_DOMAINE', + ({ valeur }) => + `Un poids est un nombre entier de 1 à ${POIDS_MAX}` + (Number.isFinite(valeur) ? ` : ${nombreEcrit(valeur)} ne l'est pas.` : '.'), + ], + [ + '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.`, + ], + // 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 + // (moteur.critere.). + ['mobilier.critere.ecartAuPlafondAPrioriMax', 'écart au plafond a priori'], + ['mobilier.critere.excedentCollisions', 'excédent de collisions'], + ['mobilier.critere.collisionsCumulees', 'collisions cumulées'], + ['mobilier.critere.toursHorsObjectif', 'tours hors objectif'], + ['mobilier.critere.rencontresRepetees', 'rencontres répétées et retours'], + ['mobilier.critere.animateursNonRencontres', 'animateurs non rencontrés'], + ['mobilier.critere.redondance', "redondance d'appartenance"], + // --- Les libellés figés du journal (§ 8.2) --------------------------------- ['ENTREE_CREER_TABLE', ({ numero }) => `Ajouté la table ${numero}`], [ @@ -165,7 +241,8 @@ export const TABLE = new Map([ ], [ 'ENTREE_MODIFIER_REGLAGE', - ({ cle, valeur }) => `${nomDans(NOMS_DES_REGLAGES, cle, 'réglage')} : ${valeurDeReglage(valeur)}`, + ({ cle, valeur }) => + `${nomDans(NOMS_DES_REGLAGES, cle, 'réglage')} : ${cle === 'modeObjectifs' ? nomMode(valeur) : valeurDeReglage(valeur)}`, ], ['ENTREE_CHANGER_CIBLE_REDONDANCE', ({ cible }) => `Cible de la somme de redondance : ${cible}`], [ @@ -179,4 +256,18 @@ export const TABLE = new Map([ ({ numero, tour }) => `${lieuObjectif(numero, tour)} : objectif${tour === null ? ' de la soirée' : ''} retiré`, ], ['ENTREE_CHANGER_REGLE_OBJECTIFS', ({ regle }) => `Règle par défaut des objectifs : ${nomRegle(regle)}`], + // Le critère déplacé, ou les critères d'un palier déplacé entier, et le + // palier où il arrive ; ceux qu'il y rejoint, ou son poids quand le geste + // ne change que lui. + [ + 'ENTREE_CHANGER_PRIORITES', + ({ criteres, palier, paliers, avec, poids }, texte) => { + const noms = enumeration(criteres.map((critere) => nomCritere(critere, texte))); + const ou = `palier ${numeroDePalier(palier)} sur ${paliers}`; + if (poids !== null) return `Ordre des priorités : ${noms} au poids ${poids}, ${ou}`; + const compagnons = avec.length > 0 ? `, avec ${enumeration(avec.map((critere) => nomCritere(critere, texte)))}` : ''; + return `Ordre des priorités : ${noms} au ${ou}${compagnons}`; + }, + ], + ['ENTREE_RETABLIR_PRIORITES', () => 'Ordre des priorités : ordre par défaut rétabli'], ]); diff --git a/src/application/libelles/moteur.js b/src/application/libelles/moteur.js index cc6309c..e287d8d 100644 --- a/src/application/libelles/moteur.js +++ b/src/application/libelles/moteur.js @@ -2,9 +2,15 @@ // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // Les textes des codes du moteur qui n'ont pas leur table ailleurs, et les -// libellés de ses critères (§ 5, § 14.6) : les codes d'une +// libellés de ses critères (§ 5, § 5.7, § 14.6) : les codes d'une // ErreurConfiguration, ceux des violations de verifierInvariants et de -// verifierIndicateurs, et moteur.critere. pour chaque nom de CRITERES. +// verifierIndicateurs ; moteur.critere. pour chacun des sept critères +// du registre, le nom que la phrase de l'ordre lui donne sous le défaut ; +// moteur.valeur. pour chaque valeur qui classe, le nom que la phrase +// donne à la valeur appliquée — « rencontres répétées », ou « retours » sous +// « nouvelle table » seule ; un critère qui en classe deux les nomme l'une +// après l'autre (A11) — ; moteur.palier.*, les mots d'un palier partagé. Le +// nom complet d'un critère, celui de l'éditeur, est mobilier.critere.. // Une violation d'indicateur qui porte sur un total ne désigne personne. // Un code garde un seul texte : CAPACITE et TOURS, que le moteur lève sous // deux formes de détails — ErreurConfiguration et violation —, se @@ -203,6 +209,25 @@ export const TABLE = new Map([ ['moteur.critere.ecartAuPlafondAPrioriMax', 'écart au plafond a priori'], ['moteur.critere.excedentCollisions', 'excédent de collisions'], ['moteur.critere.collisionsCumulees', 'collisions cumulées'], + ['moteur.critere.toursHorsObjectif', 'tours hors objectif'], ['moteur.critere.rencontresRepetees', 'rencontres répétées'], + ['moteur.critere.animateursNonRencontres', 'animateurs non rencontrés'], ['moteur.critere.redondance', "redondance d'appartenance"], + + // --- Les valeurs qui classent (§ 5.7, A11, D9, D20) ------------------------------ + ['moteur.valeur.ecartAuPlafondAPrioriMax', 'écart au plafond a priori'], + ['moteur.valeur.excedentCollisions', 'excédent de collisions'], + ['moteur.valeur.collisionsCumulees', 'collisions cumulées'], + ['moteur.valeur.jamaisServies', 'personnes jamais servies'], + ['moteur.valeur.horsObjectifMax', "plus de tours hors objectif d'une personne"], + ['moteur.valeur.toursHorsObjectif', 'tours hors objectif'], + ['moteur.valeur.rencontresRepeteesChoisies', 'rencontres répétées'], + ['moteur.valeur.retoursChoisis', 'retours'], + ['moteur.valeur.animateursNonRencontres', 'animateurs non rencontrés'], + ['moteur.valeur.redondance', "redondance d'appartenance"], + + // --- Un palier partagé (§ 5.7, A11) : « ensemble, », puis chaque critère + // suivi de son poids. + ['moteur.palier.ensemble', 'ensemble'], + ['moteur.palier.poids', ({ poids }) => `poids ${poids}`], ]); diff --git a/src/application/registre.test.js b/src/application/registre.test.js index 0c977ce..ba3ca05 100644 --- a/src/application/registre.test.js +++ b/src/application/registre.test.js @@ -33,9 +33,11 @@ import assert from 'node:assert/strict'; import { creerFichiersSimules } from '../../test/fichiers_simules.js'; import { describe, test } from '../../test/lanceur.js'; import { apercevoir } from '../csv/apercu.js'; +import { apercevoirObjectifs } from '../csv/objectifs.js'; import { CATALOGUE } from '../demo/catalogue.js'; import { LIVREES } from '../demo/livrees.js'; import { normaliser } from '../moteur/configuration.js'; +import { CRITERES } from '../moteur/criteres.js'; import { rechercher } from '../moteur/recherche.js'; import { verifierInvariants } from '../moteur/verification.js'; import { serialiserCharge } from '../stockage/canonique.js'; @@ -122,11 +124,47 @@ const REMEDE_NUL_ADMIS = new Map([ ['OBJECTIF_HORS_SOIREE', 'le tour se choisit dans le champ même, parmi ceux de la soirée'], ['OBJECTIF_SANS_ETIQUETTE', "sur un couple sans objectif, rien à retirer : la saisie se corrige dans le champ même"], ['TABLE_DISPARUE', 'une table supprimée entre-temps : aucun geste ne la rend'], + ['PRIORITES_INVALIDES', "l'éditeur n'offre que des listes valides : le texte nomme le palier et le critère en cause"], + ['POIDS_HORS_DOMAINE', 'la valeur se corrige dans le champ même, dont le texte dit la valeur attendue'], + [ + 'OBJECTIFS_PALIER_PARTAGE', + "séparer d'office déplacerait des critères que le choix du mode ne nomme pas (D23) : le texte nomme le palier, que l'opérateur sépare dans l'éditeur", + ], ]); // Le geste qui pose deux places de plus à la table 2, par son champ. const QUATRE_PLACES_A_LA_TABLE_2 = ['changerPlacesTable', { id: 2, sieges: '4', origine: 'champ' }]; +// Les arguments d'un import des objectifs qui donne « finance » à la soirée +// de la table d'identifiant 2, désignée par son numéro affiché, lu sur la +// charge où il s'exécute. +const IMPORTER_FINANCE_A_LA_TABLE_2 = (charge) => ({ + apercu: apercevoirObjectifs(`table;objectif\n${tableDe(charge, 2).numero};finance\n`, charge), + mode: 'mettreAJour', +}); + +// L'ordre des priorités par défaut, chaque critère seul à son palier ; un +// ordre réglé, où l'excédent et les collisions cumulées partagent le +// deuxième palier ; et le geste qui joint les tours hors objectif à +// l'excédent. +const PRIORITES_DEFAUT = CRITERES.map((critere) => [{ critere, poids: 1 }]); +const COLLISIONS_JOINTES = [ + PRIORITES_DEFAUT[0], + [{ critere: CRITERES[1], poids: 2 }, { critere: CRITERES[2], poids: 3 }], + ...PRIORITES_DEFAUT.slice(3), +]; +const JOINDRE_LES_TOURS_HORS_OBJECTIF = [ + 'changerPriorites', + { + paliers: [ + PRIORITES_DEFAUT[0], + [{ critere: CRITERES[1], poids: 1 }, { critere: CRITERES[3], poids: 3 }], + PRIORITES_DEFAUT[2], + ...PRIORITES_DEFAUT.slice(4), + ], + }, +]; + // La personne libre de la table 2 au tour 1, retirée dans la réserve du tour. const RETIRER_UN_LIBRE_DE_LA_TABLE = (idTable) => [ 'retirerDuTour', @@ -220,6 +258,22 @@ const EXEMPLES = new Map([ }, }, ], + [ + 'importerObjectifs', + { + accepte: { args: (charge) => IMPORTER_FINANCE_A_LA_TABLE_2(charge) }, + refuse: { + sur: 'vide', + args: (charge) => ({ apercu: apercevoirObjectifs('table;objectif\n1;finance\n', charge), mode: 'mettreAJour' }), + code: 'OBJECTIFS_SANS_TABLE', + remede: { geste: 'ouvrirSection', section: 'tables' }, + }, + sansEffet: { + avant: [['poserObjectif', { table: 2, tour: null, etiquettes: 'finance', regle: null }]], + args: (charge) => IMPORTER_FINANCE_A_LA_TABLE_2(charge), + }, + }, + ], [ 'changerEtat', { @@ -337,7 +391,12 @@ const EXEMPLES = new Map([ 'modifierReglage', { accepte: { args: { cle: 'nouveauxVoisins', valeur: false } }, - refuse: "une clé ou une valeur hors de leur domaine est une faute du code (TypeError) : l'interface n'offre que les réglages admis", + refuse: { + avant: [JOINDRE_LES_TOURS_HORS_OBJECTIF], + args: { cle: 'modeObjectifs', valeur: 'chacun' }, + code: 'OBJECTIFS_PALIER_PARTAGE', + remede: null, + }, sansEffet: { args: { cle: 'attribuerSieges', valeur: false } }, }, ], @@ -395,6 +454,26 @@ const EXEMPLES = new Map([ sansEffet: { args: { regle: 'ou' } }, }, ], + [ + 'changerPriorites', + { + accepte: { args: { paliers: COLLISIONS_JOINTES } }, + refuse: { + args: { paliers: [[...PRIORITES_DEFAUT[0], ...PRIORITES_DEFAUT[1]], ...PRIORITES_DEFAUT.slice(2)] }, + code: 'PRIORITES_INVALIDES', + remede: null, + }, + sansEffet: { args: { paliers: PRIORITES_DEFAUT } }, + }, + ], + [ + 'retablirPriorites', + { + accepte: { avant: [['changerPriorites', { paliers: COLLISIONS_JOINTES }]], args: {} }, + refuse: "rétablir l'ordre par défaut ne refuse rien du domaine : sans ordre réglé, le geste est sans effet", + sansEffet: { args: {} }, + }, + ], [ 'reserver', { @@ -1152,6 +1231,10 @@ describe('registre : étiquettes, objectifs, règle et cases ne mettent aucun pl ['modifierReglage', { cle: 'rencontrerAnimateurs', valeur: true }], ['retirerObjectif', { table: 2, tour: null }], ['changerEtiquettes', { id: 2, retirer: 'finance' }], + ['changerPriorites', { paliers: COLLISIONS_JOINTES }], + ['modifierReglage', { cle: 'modeObjectifs', valeur: 'minimum' }], + ['retablirPriorites', {}], + ['modifierReglage', { cle: 'modeObjectifs', valeur: 'places' }], ]; const depart = raisons(seance.charge); for (const [nom, args] of gestes) { @@ -1166,4 +1249,23 @@ describe('registre : étiquettes, objectifs, règle et cases ne mettent aucun pl { portee: 'tour', tour: 2, regle: 'et', etiquettes: ['Montréal', 'finance'] }, ]); }); + + test("sur la charge témoin retenue, importer des objectifs écrit une entrée, avertit IMPORT_OBJECTIFS_RECLASSE et laisse les raisons de dérive telles quelles (§ 10.4)", async () => { + const ouvert = await ouvrirCopie((await departsPrets()).temoin, { ecriture: true }); + const { seance } = ouvert; + assert.equal(seance.charge.evenement.etat, 'retenu'); + const depart = raisons(seance.charge); + const entrees = journalDe(ouvert).entrees.length; + const { charge } = seance; + const texte = `table;tour;objectif;regle\n${tableDe(charge, 2).numero};;finance;\n${tableDe(charge, 3).numero};2;finance, Montréal;et\n`; + const rendu = await seance.executer('importerObjectifs', { apercu: apercevoirObjectifs(texte, charge), mode: 'mettreAJour' }); + assert.equal(rendu.avertissements[0].code, 'IMPORT_OBJECTIFS_RECLASSE'); + assert.equal(journalDe(ouvert).entrees.length, entrees + 1); + assert.deepEqual(raisons(seance.charge), depart); + const ecrite = chargeEcrite(ouvert); + assert.deepEqual(raisons(ecrite), depart); + assert.deepEqual(ecrite.tables.find(({ id }) => id === 3).objectifs, [ + { portee: 'tour', tour: 2, regle: 'et', etiquettes: ['Montréal', 'finance'] }, + ]); + }); }); diff --git a/src/csv/colonnes.js b/src/csv/colonnes.js index c77fc0a..df866a2 100644 --- a/src/csv/colonnes.js +++ b/src/csv/colonnes.js @@ -6,6 +6,9 @@ // une colonne a été déplacée importerait sinon les noms dans les // appartenances, sans rien signaler. Deux colonnes reconnues sous le même // champ ne se départagent pas toutes seules ; le choix de l'opérateur tranche. +// La règle se lit sur une table d'en-têtes : reconnaitreSelon et +// associerSelon la portent pour toute table, celle des objectifs de table +// (§ 10.4) comprise ; reconnaitre et associer sont celles des participants. import { cleEntete } from './normalisation.js'; @@ -37,11 +40,80 @@ export const ENTETES_DES_CHAMPS = Object.freeze([ /** Champs d'un participant que porte un CSV, dans l'ordre de l'export. */ export const CHAMPS = Object.freeze(ENTETES_DES_CHAMPS.map(([nom]) => nom)); -// Champ de chaque clé d'en-tête, la clé tirée de l'écriture du § 10.1 par la -// même règle que celle d'un en-tête lu. -const CHAMP_DE_CLE = new Map( - ENTETES_DES_CHAMPS.flatMap(([champ, entetes]) => entetes.map((entete) => [cleEntete(entete), champ])), -); +/** + * La reconnaissance d'une table d'en-têtes, de la forme d'ENTETES_DES_CHAMPS : + * une fonction qui rend le champ qu'un en-tête désigne, synonymes compris, + * sans égard à la casse, aux accents, aux espaces, à « _ » ni à « - » ; null + * pour un en-tête inconnu. La clé de chaque en-tête de la table se tire de + * son écriture par la même règle que celle d'un en-tête lu (cleEntete). La + * table se lit une fois, à l'appel : la changer ensuite ne change rien. + * + * @param {ReadonlyArray]>} table + * @returns {(entete: string) => string|null} + * @throws {RangeError} quand deux champs partagent une clé d'en-tête, qu'il + * nomme : la colonne irait à l'un ou à l'autre selon l'ordre de la table. + * Un champ peut répéter la sienne, comme « règle » pour regle. + */ +export function reconnaitreSelon(table) { + const champDeCle = new Map(); + for (const [champ, entetes] of table) { + for (const entete of entetes) { + const cle = cleEntete(entete); + const deja = champDeCle.get(cle); + if (deja !== undefined && deja !== champ) { + throw new RangeError(`en-têtes : la clé ${JSON.stringify(cle)} est celle de ${deja} et de ${champ}`); + } + champDeCle.set(cle, champ); + } + } + return (entete) => champDeCle.get(cleEntete(entete)) ?? null; +} + +/** + * L'association des colonnes selon une table d'en-têtes, de la forme + * d'ENTETES_DES_CHAMPS : une fonction (entetes, choix) qui suit la règle + * d'associer (voir associer), les champs pris dans l'ordre de la table. La + * table se lit une fois, à l'appel. + * + * @param {ReadonlyArray]>} table + * @returns {(entetes: string[], choix?: Object) => { + * colonnes: Object, + * ambigus: Array<{champ: string, rangs: number[]}>, + * nonReconnues: number[], + * }} + * @throws {RangeError} quand deux champs partagent une clé d'en-tête + * (reconnaitreSelon) + */ +export function associerSelon(table) { + const reconnaitreLu = reconnaitreSelon(table); + const champs = table.map(([champ]) => champ); + return (entetes, choix = {}) => { + const rangsDesChamps = new Map(champs.map((champ) => [champ, []])); + const nonReconnues = []; + entetes.forEach((entete, rang) => { + const champ = reconnaitreLu(entete); + if (champ === null) nonReconnues.push(rang); + else rangsDesChamps.get(champ).push(rang); + }); + + const colonnes = {}; + const ambigus = []; + for (const champ of champs) { + const rangs = rangsDesChamps.get(champ); + if (rangs.length === 1) { + colonnes[champ] = rangs[0]; + } else if (rangs.length > 1) { + if (Object.hasOwn(choix, champ) && rangs.includes(choix[champ])) colonnes[champ] = choix[champ]; + else ambigus.push({ champ, rangs }); + } + } + return { colonnes, ambigus, nonReconnues }; + }; +} + +// La reconnaissance et l'association des participants. +const RECONNAITRE_PARTICIPANT = reconnaitreSelon(ENTETES_DES_CHAMPS); +const ASSOCIER_PARTICIPANTS = associerSelon(ENTETES_DES_CHAMPS); /** * Champ qu'un en-tête désigne, synonymes compris, sans égard à la casse, aux @@ -52,7 +124,7 @@ const CHAMP_DE_CLE = new Map( * @returns {string|null} un élément de CHAMPS, ou null */ export function reconnaitre(entete) { - return CHAMP_DE_CLE.get(cleEntete(entete)) ?? null; + return RECONNAITRE_PARTICIPANT(entete); } /** @@ -77,24 +149,5 @@ export function reconnaitre(entete) { * croissants des colonnes dont l'en-tête est inconnu, vide compris. */ export function associer(entetes, choix = {}) { - const rangsDesChamps = new Map(CHAMPS.map((champ) => [champ, []])); - const nonReconnues = []; - entetes.forEach((entete, rang) => { - const champ = reconnaitre(entete); - if (champ === null) nonReconnues.push(rang); - else rangsDesChamps.get(champ).push(rang); - }); - - const colonnes = {}; - const ambigus = []; - for (const champ of CHAMPS) { - const rangs = rangsDesChamps.get(champ); - if (rangs.length === 1) { - colonnes[champ] = rangs[0]; - } else if (rangs.length > 1) { - if (Object.hasOwn(choix, champ) && rangs.includes(choix[champ])) colonnes[champ] = choix[champ]; - else ambigus.push({ champ, rangs }); - } - } - return { colonnes, ambigus, nonReconnues }; + return ASSOCIER_PARTICIPANTS(entetes, choix); } diff --git a/src/csv/colonnes.test.js b/src/csv/colonnes.test.js index 00176f1..618d0a5 100644 --- a/src/csv/colonnes.test.js +++ b/src/csv/colonnes.test.js @@ -4,10 +4,12 @@ // Épreuves de l'association des colonnes (§ 10.1) : les champs du CSV, la // reconnaissance d'un en-tête et de ses synonymes, et l'association par // en-tête, jamais par position, qui laisse sans colonne un champ que deux -// colonnes revendiquent tant qu'un choix ne tranche pas. +// colonnes revendiquent tant qu'un choix ne tranche pas. associerSelon et +// reconnaitreSelon portent la même règle sur une autre table d'en-têtes, +// celle du CSV des objectifs de table (§ 10.4). import assert from 'node:assert/strict'; import { describe, test } from '../../test/lanceur.js'; -import { CHAMPS, ENTETES_DES_CHAMPS, associer, reconnaitre } from './colonnes.js'; +import { CHAMPS, ENTETES_DES_CHAMPS, associer, associerSelon, reconnaitre, reconnaitreSelon } from './colonnes.js'; import { cleEntete } from './normalisation.js'; describe('CHAMPS', () => { @@ -240,3 +242,82 @@ describe('associer : par en-tête, jamais par position (§ 10.1)', () => { assert.deepEqual(associer([]), { colonnes: {}, ambigus: [], nonReconnues: [] }); }); }); + +// Une table d'en-têtes inventée, d'un autre fichier que celui des +// participants : deux champs, des synonymes, et un synonyme de même clé que +// le nom de son champ, comme « règle » pour regle. +const TABLE_INVENTEE = Object.freeze([ + Object.freeze(['salle', Object.freeze(['salle', 'pièce', 'local'])]), + Object.freeze(['couleur', Object.freeze(['couleur', 'Couleur', 'teinte'])]), +]); + +describe('associerSelon et reconnaitreSelon : la même règle sur une autre table (§ 10.1, § 10.4)', () => { + test('sur la table des participants, associerSelon rend exactement ce que rend associer', () => { + const associerParticipants = associerSelon(ENTETES_DES_CHAMPS); + const cas = [ + [['nom', 'prenom', 'appartenance', 'courriel', 'titre_pressenti', 'exclu', 'notes', 'etiquettes'], {}], + [['notes', 'exclu', 'courriel', 'nom'], {}], + [['nom', 'organisation', 'entreprise'], {}], + [['nom', 'organisation', 'entreprise'], { appartenance: 2 }], + [['nom', 'organisation', 'entreprise', 'inconnue'], { appartenance: 3 }], + [['titre', 'équipe', 'nom', 'rôle', 'organisation', 'entreprise'], {}], + [['x', 'nom', '', 'ligne', ' '], {}], + [[], {}], + ]; + for (const [entetes, choix] of cas) { + const rendu = associerParticipants(entetes, choix); + assert.deepEqual(rendu, associer(entetes, choix), JSON.stringify([entetes, choix])); + assert.equal(JSON.stringify(rendu.colonnes), JSON.stringify(associer(entetes, choix).colonnes)); + } + const reconnaitreParticipants = reconnaitreSelon(ENTETES_DES_CHAMPS); + for (const entete of ['NOM', 'Équipe', 'mots-clés', 'ligne', 'motif', '', 'table']) { + assert.equal(reconnaitreParticipants(entete), reconnaitre(entete), entete); + } + }); + + test('sur une autre table, chaque champ par ses en-têtes ; ceux des participants y sont inconnus', () => { + const reconnaitreInventee = reconnaitreSelon(TABLE_INVENTEE); + assert.deepEqual( + ['SALLE', 'Pièce', 'lo cal', 'couleur', 'TEINTE', 'nom', 'etiquettes', 'ligne', 'motif', ''].map(reconnaitreInventee), + ['salle', 'salle', 'salle', 'couleur', 'couleur', null, null, null, null, null], + ); + assert.equal(reconnaitre('salle'), null); + }); + + test("sur une autre table, l'association suit l'ordre de la table, laisse ambigu un champ revendiqué deux fois, et le choix tranche", () => { + const associerInventee = associerSelon(TABLE_INVENTEE); + assert.deepEqual(associerInventee(['teinte', 'nom', 'local']), { + colonnes: { salle: 2, couleur: 0 }, + ambigus: [], + nonReconnues: [1], + }); + assert.equal(JSON.stringify(associerInventee(['teinte', 'local']).colonnes), '{"salle":1,"couleur":0}'); + assert.deepEqual(associerInventee(['pièce', 'teinte', 'salle']), { + colonnes: { couleur: 1 }, + ambigus: [{ champ: 'salle', rangs: [0, 2] }], + nonReconnues: [], + }); + assert.deepEqual(associerInventee(['pièce', 'teinte', 'salle'], { salle: 2 }).colonnes, { salle: 2, couleur: 1 }); + assert.deepEqual(associerInventee(['pièce', 'teinte', 'salle'], { salle: 1 }).ambigus, [{ champ: 'salle', rangs: [0, 2] }]); + }); + + test("deux champs qui partagent une clé d'en-tête lèvent RangeError, qui nomme la clé ; un champ peut répéter la sienne", () => { + const conflit = [ + ['salle', ['salle', 'pièce']], + ['couleur', ['couleur', 'Piece']], + ]; + assert.throws(() => associerSelon(conflit), (erreur) => erreur instanceof RangeError && /piece/.test(erreur.message)); + assert.throws(() => reconnaitreSelon(conflit), RangeError); + assert.equal(reconnaitreSelon(TABLE_INVENTEE)('Couleur'), 'couleur'); + }); + + test('la table reçue est lue une fois : la changer ensuite ne change pas la reconnaissance', () => { + const table = [['salle', ['salle']]]; + const reconnaitreLue = reconnaitreSelon(table); + const associerLue = associerSelon(table); + table[0][1].push('pièce'); + table.push(['couleur', ['couleur']]); + assert.equal(reconnaitreLue('pièce'), null); + assert.deepEqual(associerLue(['couleur', 'salle']), { colonnes: { salle: 1 }, ambigus: [], nonReconnues: [0] }); + }); +}); diff --git a/src/csv/objectifs.js b/src/csv/objectifs.js new file mode 100644 index 0000000..612bb26 --- /dev/null +++ b/src/csv/objectifs.js @@ -0,0 +1,571 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le CSV des objectifs de table (§ 10.4) : un second fichier, que +// l'opérateur choisit d'importer comme tel, jamais une section du fichier +// des participants. Il garde son séparateur, son en-tête et son CSV des +// refus ; tout le § 10.1 vaut, sauf ce qui nomme les participants. Ce module +// en porte l'aperçu, l'import appliqué selon le mode, ce que remplacer +// touche et l'export, qui est le gabarit de l'import et sa sauvegarde. +// +// La lecture reprend, dans l'ordre, celle de l'aperçu des participants : +// 1. un caractère nul refuse le texte ; +// 2. choisirSeparateur choisit le séparateur sur la table d'en-têtes des +// objectifs ; un texte vide ou blanc est refusé AUCUN_ENTETE, tout autre +// texte sans séparateur SEPARATEUR_INTROUVABLE, sauf quand le seul +// défaut est un guillemet resté ouvert, que le point 3 refuse ; +// 3. un guillemet resté ouvert refuse le texte, au rang où il s'ouvre ; +// 4. aucun en-tête reconnu, puis aucune colonne associée à table +// (TABLE_NON_ASSOCIEE), puis à objectif (OBJECTIF_NON_ASSOCIE), puis un +// événement sans table (OBJECTIFS_SANS_TABLE) refusent ; +// 5. chaque enregistrement non vide qui suit l'en-tête est une ligne, +// valide ou refusée pour un seul motif (lireLigne) ; zéro ligne valide +// refuse (AUCUNE_LIGNE_VALIDE) ; +// 6. les étiquettes des lignes valides se réconcilient avec celles de +// l'événement : celles des personnes, exclues comprises, et celles des +// objectifs de table, sauf sous remplacer, qui retire ces objectifs. +// Une ligne refusée ne refuse pas le texte : le reste s'importe, et la ligne +// revient dans le CSV des refus (exporterRefus de import.js, tel quel), dont +// les colonnes ligne et motif restent inconnues de la table des en-têtes. +// +// La table d'une ligne se désigne par son numéro affiché, jamais par son +// identifiant : l'opérateur lit le numéro sur le plan. Une valeur inconnue +// de la règle refuse la ligne : la lire comme le défaut ferait servir un +// objectif sous une règle que personne n'a choisie. + +import { ETIQUETTES_MAX, lireEtiquettes } from '../stockage/etiquettes.js'; +import { associerSelon, reconnaitreSelon } from './colonnes.js'; +import { contientNul } from './encodage.js'; +import { ErreurCsv } from './erreurs.js'; +import { cleEtiquette, reconcilier } from './etiquettes.js'; +import { JOINTURE_ETIQUETTES, SEPARATEUR, ecrireCsv } from './export.js'; +import { choisirSeparateur, decouper, enregistrementVide } from './lecture.js'; +import { cleNormalisee } from './normalisation.js'; + +// Un champ et ses en-têtes, figés. +const champ = (nom, entetes) => Object.freeze([nom, Object.freeze(entetes)]); + +/** + * La table des colonnes du CSV des objectifs (§ 10.4), seule source de ses + * champs et de leurs synonymes : chaque champ, dans l'ordre de l'export, avec + * ses en-têtes tels que le § 10.4 les écrit — son nom, puis ses synonymes. + * « règle » a la clé de « regle » : il ne fait que redire que l'accent + * n'importe pas. Le tableau des colonnes de la documentation s'en engendre. + * Figée à toute profondeur. + * + * @type {ReadonlyArray]>} + */ +export const ENTETES_DES_OBJECTIFS = Object.freeze([ + champ('table', ['table']), + champ('tour', ['tour']), + champ('objectif', ['objectif', 'objectifs']), + champ('regle', ['regle', 'règle']), +]); + +/** Champs du CSV des objectifs, dans l'ordre de l'export : son en-tête. */ +export const CHAMPS_OBJECTIFS = Object.freeze(ENTETES_DES_OBJECTIFS.map(([nom]) => nom)); + +/** + * Champ des objectifs qu'un en-tête désigne, par la règle de reconnaitre + * (colonnes.js) ; null pour un en-tête inconnu, ceux des participants, ligne + * et motif compris. + * + * @type {(entete: string) => string|null} + */ +export const reconnaitreObjectif = reconnaitreSelon(ENTETES_DES_OBJECTIFS); + +/** + * L'association des colonnes du CSV des objectifs, par la règle d'associer + * (colonnes.js), les champs dans l'ordre de CHAMPS_OBJECTIFS. + * + * @type {ReturnType} + */ +export const associerObjectifs = associerSelon(ENTETES_DES_OBJECTIFS); + +/** + * Modes de l'import des objectifs, que l'aperçu et l'import reçoivent tous + * deux (§ 10.4). Ajouter n'en est pas un : un couple (table, tour) déjà + * pourvu serait une collision, que mettre à jour tranche. + * + * @type {readonly string[]} + */ +export const MODES_IMPORT_OBJECTIFS = Object.freeze(['mettreAJour', 'remplacer']); + +/** + * Valeurs admises de la règle, par clé normalisée (cleNormalisee), comme + * celles d'« exclu » : une liste fermée. 'ou' et 'et' pour une règle propre ; + * la clé vide, null, pour un objectif qui suit la règle par défaut de + * l'événement. Une valeur absente de la liste refuse la ligne. + * + * @type {Map} + */ +export const VALEURS_REGLE = new Map([ + ['ou', 'ou'], + ['une', 'ou'], + ['au moins une', 'ou'], + ['et', 'et'], + ['toutes', 'et'], + ['', null], +]); + +// Un entier écrit en chiffres décimaux, un signe admis : « 2.0 », « deux » +// et « 1,5 » n'en sont pas. +const ENTIER = /^[+-]?[0-9]+$/; + +// L'ordre des objectifs d'une table (§ 8.9) : la soirée, de tour null, +// avant les tours, puis les tours croissants. +const ordreDesObjectifs = (a, b) => (a.tour ?? 0) - (b.tour ?? 0); + +// 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); +const rangees = (etiquettes) => [...etiquettes].sort(comparerTextes); + +/** + * @typedef {Object} ChampsObjectif + * @property {number} numero le numéro affiché de la table visée + * @property {number} table son identifiant + * @property {number|null} tour null pour la soirée, sinon de 1 à R + * @property {'ou'|'et'|null} regle null quand l'objectif suit la règle par + * défaut de l'événement + * @property {string[]} etiquettes découpées par lireEtiquettes, dans + * l'ordre de la cellule, chacune sous l'orthographe que la + * réconciliation lui donne, sans deux de même clé, de 1 à + * ETIQUETTES_MAX + * + * @typedef {Object} LigneObjectifRefusee + * @property {number} ligne rang de l'enregistrement, en-tête compris + * @property {string} code CHAMPS_EN_TROP, 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 ou OBJECTIF_EN_DOUBLE + * @property {string|null} valeur la cellule fautive, en NFC sans ses blancs + * de bord, sous les codes de la table, du tour et de la règle ; + * l'étiquette fautive, mise en forme, sous ETIQUETTE_MAL_FORMEE ; + * null sous les autres + * @property {number} [tours] TOUR_HORS_SOIREE : le nombre de tours + * @property {string} [raison] ETIQUETTE_MAL_FORMEE : 'controle' ou + * 'longueur' + * @property {number} [nombre] ETIQUETTES_TROP_NOMBREUSES : les + * étiquettes de clés distinctes de la cellule + * @property {number} [premiereLigne] OBJECTIF_EN_DOUBLE : la ligne valide + * qui donne la première l'objectif de ce couple + * @property {number} [numero] OBJECTIF_EN_DOUBLE : le numéro de la table + * @property {number|null} [tour] OBJECTIF_EN_DOUBLE : le tour, null pour la + * soirée + * @property {string[]} brut les champs tels que découpés + * + * @typedef {Object} ApercuObjectifs + * @property {'mettreAJour'|'remplacer'} mode le mode pour lequel l'aperçu + * est calculé : appliquerImportObjectifs ne l'applique que dans ce + * mode + * @property {string|null} separateur null quand aucun séparateur n'est + * établi, ou pour un fichier à une colonne + * @property {string[]} entetes tels qu'écrits + * @property {{colonnes: Object, ambigus: Array<{champ: string, rangs: number[]}>, + * nonReconnues: number[]}} association ce que rend associerObjectifs + * @property {Array<{ligne: number, champs: ChampsObjectif, brut: string[]}>} lignes + * les lignes valides, dans l'ordre du texte + * @property {LigneObjectifRefusee[]} refusees rangées par ligne + * @property {import('./etiquettes.js').FusionEtiquettes[]} fusionsEtiquettes + * les fusions d'orthographes que rend reconcilier sur les lignes + * valides + * @property {Array<{ligne: number, rangs: number[]}>} multilignes chaque ligne + * valide dont une cellule s'étend sur plusieurs lignes du texte et + * porte le séparateur — la forme d'un guillemet égaré qui fond deux + * lignes en une —, avec les rangs croissants de ces cellules + * @property {null|'CARACTERE_NUL'|'AUCUN_ENTETE'|'SEPARATEUR_INTROUVABLE'|'GUILLEMET_OUVERT'|'TABLE_NON_ASSOCIEE'|'OBJECTIF_NON_ASSOCIE'|'OBJECTIFS_SANS_TABLE'|'AUCUNE_LIGNE_VALIDE'} refusGlobal + * sous un refus global, lignes, fusionsEtiquettes et multilignes + * sont vides, et refusees aussi, sauf sous AUCUNE_LIGNE_VALIDE + * @property {number|null} refusGlobalLigne sous GUILLEMET_OUVERT, le rang où + * le guillemet s'ouvre ; sous SEPARATEUR_INTROUVABLE, celui de + * l'enregistrement qui écarte le séparateur, quand il est nommé ; + * null sinon + */ + +// Aperçu d'un texte refusé en entier : son mode, ce que la lecture a établi +// avant le refus, aucune ligne. +function refuserEn( + mode, + code, + { separateur = null, entetes = [], association = { colonnes: {}, ambigus: [], nonReconnues: [] } } = {}, + ligne = null, +) { + return { + mode, + separateur, + entetes, + association, + lignes: [], + refusees: [], + fusionsEtiquettes: [], + multilignes: [], + refusGlobal: code, + refusGlobalLigne: ligne, + }; +} + +// Valeur de la cellule de rang donné : NFC, blancs de bord retirés. Null quand +// le champ n'a pas de colonne, que l'enregistrement s'arrête avant elle, ou +// que la cellule est vide ou blanche. +function valeurDe(brut, rang) { + if (rang === undefined || rang >= brut.length) return null; + const valeur = brut[rang].normalize('NFC').trim(); + return valeur === '' ? null : valeur; +} + +// La table qu'un numéro écrit désigne, ou le motif du refus : TABLE_ABSENTE +// pour une cellule vide, TABLE_NON_ENTIERE pour ce qui n'est pas un entier, +// NUMERO_TABLE_INCONNU quand aucune table ne porte ce numéro, PLUSIEURS_TABLES +// quand deux le portent : la ligne ne dit pas laquelle elle vise. +function tableDe(valeur, tables) { + if (valeur === null) return { refus: { code: 'TABLE_ABSENTE', valeur: null } }; + if (!ENTIER.test(valeur)) return { refus: { code: 'TABLE_NON_ENTIERE', valeur } }; + const numero = Number(valeur); + const portees = tables.filter((candidate) => candidate.numero === numero); + if (portees.length === 0) return { refus: { code: 'NUMERO_TABLE_INCONNU', valeur } }; + if (portees.length > 1) return { refus: { code: 'PLUSIEURS_TABLES', valeur } }; + return { table: portees[0] }; +} + +// Le tour qu'une cellule écrit : null pour une cellule vide, la soirée ; un +// entier de 1 à tours ; ou le motif du refus, TOUR_NON_ENTIER, ou +// TOUR_HORS_SOIREE qui nomme le nombre de tours. +function tourDe(valeur, tours) { + if (valeur === null) return { tour: null }; + if (!ENTIER.test(valeur)) return { refus: { code: 'TOUR_NON_ENTIER', valeur } }; + const tour = Number(valeur); + if (tour < 1 || tour > tours) return { refus: { code: 'TOUR_HORS_SOIREE', valeur, tours } }; + return { tour }; +} + +// Les étiquettes de la cellule objectif (§ 4.5, § 10.1), par la découpe de +// la saisie : la cellule arrive telle que découpée, sans le trim de valeurDe, +// qui retirerait au bord une tabulation que lireEtiquettes refuse. +// OBJECTIF_VIDE pour une cellule absente, vide ou faite de blancs et de +// séparateurs ; puis ETIQUETTE_MAL_FORMEE, la première fautive avec sa +// raison ; puis ETIQUETTES_TROP_NOMBREUSES au-delà d'ETIQUETTES_MAX clés +// distinctes : deux morceaux de même clé ne font qu'une étiquette. +function etiquettesDe(cellule) { + const { etiquettes, fautes } = lireEtiquettes(cellule ?? ''); + if (etiquettes.length === 0 && fautes.length === 0) return { refus: { code: 'OBJECTIF_VIDE', valeur: null } }; + if (fautes.length > 0) { + const [{ valeur, raison }] = fautes; + return { refus: { code: 'ETIQUETTE_MAL_FORMEE', valeur, raison } }; + } + const nombre = new Set(etiquettes.map(cleEtiquette)).size; + if (nombre > ETIQUETTES_MAX) return { refus: { code: 'ETIQUETTES_TROP_NOMBREUSES', valeur: null, nombre } }; + return { etiquettes }; +} + +// Lit un enregistrement de données selon les colonnes associées, largeur +// étant le nombre d'en-têtes. Rend { champs } ou le motif du refus, un seul, +// dans l'ordre du § 10.4 : un champ non vide au-delà de l'en-tête ; le +// numéro de table ; le tour ; l'objectif ; la règle hors de sa liste fermée. +// Le doublon d'un couple (table, tour) se juge après, contre les lignes +// valides qui précèdent. +function lireLigne(brut, largeur, colonnes, { tables, tours }) { + if (!enregistrementVide(brut.slice(largeur))) return { code: 'CHAMPS_EN_TROP', valeur: null }; + const lire = (nom) => valeurDe(brut, colonnes[nom]); + const visee = tableDe(lire('table'), tables); + if (visee.refus !== undefined) return visee.refus; + const lu = tourDe(lire('tour'), tours); + if (lu.refus !== undefined) return lu.refus; + const objectif = etiquettesDe(colonnes.objectif < brut.length ? brut[colonnes.objectif] : undefined); + if (objectif.refus !== undefined) return objectif.refus; + const valeurRegle = lire('regle'); + const regle = VALEURS_REGLE.get(cleNormalisee(valeurRegle ?? '')); + if (regle === undefined) return { code: 'REGLE_INCONNUE', valeur: valeurRegle }; + return { + champs: { numero: visee.table.numero, table: visee.table.id, tour: lu.tour, regle, etiquettes: objectif.etiquettes }, + }; +} + +// Cellules d'une ligne sur plusieurs lignes du texte qui portent le +// séparateur, ou toute fin de ligne sans séparateur : les lignes valides qui +// en ont, avec leurs rangs. +function multilignesDe(lignes, separateur) { + const multilignes = []; + for (const { ligne, brut } of lignes) { + const rangs = []; + brut.forEach((cellule, rang) => { + const surPlusieursLignes = cellule.includes('\n') || cellule.includes('\r'); + if (surPlusieursLignes && (separateur === null || cellule.includes(separateur))) rangs.push(rang); + }); + if (rangs.length > 0) multilignes.push({ ligne, rangs }); + } + return multilignes; +} + +// Les étiquettes de l'événement qui donnent leur orthographe à celles du +// fichier : celles des personnes, exclues comprises, puis celles des +// objectifs de table, sauf sous remplacer, qui les retire. reconcilier ne +// dépend pas de leur ordre. +function etiquettesExistantes({ participants, tables }, mode) { + return [ + ...participants.flatMap(({ etiquettes = [] }) => etiquettes), + ...(mode === 'remplacer' ? [] : tables.flatMap(({ objectifs = [] }) => objectifs.flatMap(({ etiquettes }) => etiquettes))), + ]; +} + +// La clé d'un couple (table, tour), la soirée sous tour null. +const cleDuCouple = (table, tour) => JSON.stringify([table, tour]); + +/** + * Aperçu d'un texte décodé du CSV des objectifs (§ 10.4), contre la charge, + * pour un mode de l'import : rien n'est importé. Les lignes se lisent dans + * l'ordre du texte ; une ligne refusée ne refuse que soi. Le doublon d'un + * couple (table, tour) se juge contre les lignes valides qui précèdent : une + * ligne refusée n'est la première d'aucun couple. + * + * @param {string} texte le texte que rend decoder + * @param {{evenement: {tours: number}, tables: Array<{id: number, numero: number, + * objectifs?: Array<{etiquettes: string[]}>}>, participants: Array<{etiquettes?: string[]}>}} charge + * la charge de l'événement : ses tables, leurs numéros et leurs objectifs, + * les étiquettes de ses personnes, son nombre de tours + * @param {Object} [options] + * @param {Object} [options.choix] { champ: rang } qui + * tranche une ambiguïté de l'en-tête (voir associer) + * @param {'mettreAJour'|'remplacer'} [options.mode] mettreAJour par défaut + * @returns {ApercuObjectifs} + * @throws {TypeError} quand texte n'est pas une chaîne, pour un mode inconnu, + * et pour un nombre de tours qui n'est pas un entier positif + */ +export function apercevoirObjectifs(texte, charge, { choix = {}, mode = 'mettreAJour' } = {}) { + if (typeof texte !== 'string') throw new TypeError('texte : chaîne attendue'); + if (!MODES_IMPORT_OBJECTIFS.includes(mode)) { + throw new TypeError(`mode : ${MODES_IMPORT_OBJECTIFS.join(', ')} attendu, reçu ${String(mode)}`); + } + const { tours } = charge.evenement; + if (!Number.isSafeInteger(tours) || tours < 1) throw new TypeError(`tours : entier positif attendu, reçu ${String(tours)}`); + const refuser = (...refus) => refuserEn(mode, ...refus); + if (contientNul(texte)) return refuser('CARACTERE_NUL'); + + let separateur; + try { + ({ separateur } = choisirSeparateur(texte, reconnaitreObjectif)); + } catch (erreur) { + // Sur une chaîne, choisirSeparateur ne lève que SEPARATEUR_INTROUVABLE ; + // toute autre erreur est un défaut, qu'un refus de l'aperçu masquerait. + if (!(erreur instanceof ErreurCsv) || erreur.code !== 'SEPARATEUR_INTROUVABLE') throw erreur; + if (texte.trim() === '') return refuser('AUCUN_ENTETE'); + const { cause = null, ligne = null } = erreur.details; + if (cause !== 'GUILLEMET_OUVERT') return refuser('SEPARATEUR_INTROUVABLE', {}, ligne); + ({ separateur } = erreur.details); + } + + const { enregistrements, guillemetOuvert } = decouper(texte, separateur); + const indiceEntete = enregistrements.findIndex((enregistrement) => !enregistrementVide(enregistrement)); + const entetes = enregistrements[indiceEntete]; + const association = associerObjectifs(entetes, choix); + const lu = { separateur, entetes, association }; + if (guillemetOuvert) return refuser('GUILLEMET_OUVERT', lu, enregistrements.length); + if (association.nonReconnues.length === entetes.length) return refuser('AUCUN_ENTETE', lu); + if (association.colonnes.table === undefined) return refuser('TABLE_NON_ASSOCIEE', lu); + if (association.colonnes.objectif === undefined) return refuser('OBJECTIF_NON_ASSOCIE', lu); + if (charge.tables.length === 0) return refuser('OBJECTIFS_SANS_TABLE', lu); + + const valides = []; + const refusees = []; + const premieres = new Map(); + for (let indice = indiceEntete + 1; indice < enregistrements.length; indice += 1) { + const brut = enregistrements[indice]; + if (enregistrementVide(brut)) continue; + const ligne = indice + 1; + const { champs, ...refus } = lireLigne(brut, entetes.length, association.colonnes, { tables: charge.tables, tours }); + if (champs === undefined) { + refusees.push({ ligne, ...refus, brut }); + continue; + } + const cle = cleDuCouple(champs.table, champs.tour); + if (premieres.has(cle)) { + refusees.push({ + ligne, code: 'OBJECTIF_EN_DOUBLE', valeur: null, premiereLigne: premieres.get(cle), numero: champs.numero, + tour: champs.tour, brut, + }); + continue; + } + premieres.set(cle, ligne); + valides.push({ ligne, champs, brut }); + } + if (valides.length === 0) return { ...refuser('AUCUNE_LIGNE_VALIDE', lu), refusees }; + + const { listes, fusions: fusionsEtiquettes } = reconcilier( + etiquettesExistantes(charge, mode), + valides.map(({ champs }) => champs.etiquettes), + ); + const lignes = valides.map(({ ligne, champs, brut }, rang) => ({ ligne, champs: { ...champs, etiquettes: listes[rang] }, brut })); + return { + mode, + ...lu, + lignes, + refusees, + fusionsEtiquettes, + multilignes: multilignesDe(lignes, separateur), + refusGlobal: null, + refusGlobalLigne: null, + }; +} + +/** + * Ce que remplacer toucherait, à annoncer avant d'agir (§ 10.4) : les + * objectifs de l'événement, que remplacer retire tous, et les tables qui en + * portent. La charge n'est pas modifiée. + * + * @param {{tables: Array<{objectifs?: Array}>}} charge + * @returns {{objectifs: number, tables: number}} + */ +export function bilanRemplacementObjectifs({ tables }) { + const portantes = tables.filter(({ objectifs = [] }) => objectifs.length > 0); + return { objectifs: portantes.reduce((somme, { objectifs }) => somme + objectifs.length, 0), tables: portantes.length }; +} + +// Deux objectifs de même règle et de mêmes étiquettes, rangées. +const memesObjectifs = (a, b) => a.regle === b.regle && JSON.stringify(a.etiquettes) === JSON.stringify(b.etiquettes); + +/** + * @typedef {Object} ResumeImportObjectifs + * @property {number} ajoutes objectifs d'un couple qui n'en portait pas + * @property {number} remplaces objectifs qui remplacent un objectif + * différent du même couple + * @property {number} inchanges lignes qui redonnent l'objectif de leur couple + * @property {number} retires sous remplacer, les objectifs d'un couple + * que le fichier ne nomme pas ; 0 sous mettreAJour + * @property {number} tables les tables dont les objectifs changent + * @property {LigneObjectifRefusee[]} refusees ceux de l'aperçu, copiés + * @property {boolean} derive vrai en état retenu : l'import y avertit + * que le classement peut changer, sans mettre aucune proposition + * en dérive + */ + +/** + * Applique un aperçu des objectifs à une charge (§ 10.4), selon le mode : + * - 'mettreAJour' : chaque couple (table, tour) du fichier remplace + * l'objectif de ce couple ; les autres restent ; + * - 'remplacer' : chaque objectif de l'événement part, puis ceux du fichier + * s'écrivent. + * Un objectif écrit porte la portée « tous » pour la soirée, « tour » pour un + * tour, sa règle — null quand il suit le défaut — et ses étiquettes rangées + * par unités de code ; les objectifs d'une table se rangent la soirée + * d'abord, puis par tour croissant, et une table qui n'en garde aucun perd + * son champ objectifs (§ 8.9). Personnes, réservations, titres, + * propositions et retenu restent. La charge et l'aperçu reçus ne sont pas + * modifiés ; la charge rendue n'en partage aucun objet. + * + * @param {import('../stockage/types.js').Charge} charge + * @param {ApercuObjectifs} apercu calculé contre cette charge, pour ce mode + * @param {'mettreAJour'|'remplacer'} mode + * @returns {{charge: import('../stockage/types.js').Charge, resume: ResumeImportObjectifs}} + * @throws {ErreurCsv} dans cet ordre : PLAN_BLOQUE, détails {}, en état + * bloqué ; le refus global de l'aperçu, son code, détails { ligne } ; + * AMBIGUITE, détails { champs }, tant que le choix ne tranche pas + * @throws {TypeError} pour un mode inconnu, un aperçu calculé pour un autre + * mode, et une ligne dont la table manque à la charge : l'aperçu montré à + * l'opérateur est celui que l'import applique + */ +export function appliquerImportObjectifs(charge, apercu, mode) { + if (!MODES_IMPORT_OBJECTIFS.includes(mode)) { + throw new TypeError(`mode : ${MODES_IMPORT_OBJECTIFS.join(', ')} attendu, reçu ${String(mode)}`); + } + if (apercu.mode !== mode) throw new TypeError(`aperçu calculé pour ${String(apercu.mode)}, import en ${mode}`); + if (charge.evenement.etat === 'bloque') throw new ErreurCsv('PLAN_BLOQUE'); + if (apercu.refusGlobal !== null) throw new ErreurCsv(apercu.refusGlobal, { ligne: apercu.refusGlobalLigne }); + const { ambigus } = apercu.association; + if (ambigus.length > 0) throw new ErreurCsv('AMBIGUITE', { champs: ambigus.map(({ champ: nom }) => nom) }); + + const resultat = structuredClone(charge); + const parId = new Map(resultat.tables.map((table) => [table.id, table])); + const avant = new Map(resultat.tables.map((table) => [table.id, JSON.stringify(table.objectifs ?? [])])); + const ecrits = new Set(); + let ajoutes = 0; + let remplaces = 0; + let inchanges = 0; + for (const { champs } of apercu.lignes) { + const table = parId.get(champs.table); + if (table === undefined) throw new TypeError(`aperçu : la table ${champs.table} manque à la charge`); + const objectif = { + portee: champs.tour === null ? 'tous' : 'tour', + tour: champs.tour, + regle: champs.regle, + etiquettes: rangees(champs.etiquettes), + }; + const objectifs = table.objectifs ?? []; + const rang = objectifs.findIndex(({ tour }) => tour === champs.tour); + if (rang === -1) ajoutes += 1; + else if (memesObjectifs(objectifs[rang], objectif)) inchanges += 1; + else remplaces += 1; + table.objectifs = [...objectifs.filter(({ tour }) => tour !== champs.tour), objectif].sort(ordreDesObjectifs); + ecrits.add(cleDuCouple(champs.table, champs.tour)); + } + let retires = 0; + if (mode === 'remplacer') { + for (const table of resultat.tables) { + const gardes = (table.objectifs ?? []).filter(({ tour }) => ecrits.has(cleDuCouple(table.id, tour))); + retires += (table.objectifs ?? []).length - gardes.length; + table.objectifs = gardes; + } + } + for (const table of resultat.tables) if (table.objectifs?.length === 0) delete table.objectifs; + const tables = resultat.tables.filter((table) => JSON.stringify(table.objectifs ?? []) !== avant.get(table.id)).length; + return { + charge: resultat, + resume: { + ajoutes, + remplaces, + inchanges, + retires, + tables, + refusees: structuredClone(apercu.refusees), + derive: charge.evenement.etat === 'retenu', + }, + }; +} + +// La cellule regle d'un objectif : 'ou', 'et', ou vide quand il suit le +// défaut ; toute autre valeur lève TypeError, qui nomme la table. +function celluleRegle(regle, numero) { + if (regle === null) return ''; + if (regle === 'ou' || regle === 'et') return regle; + throw new TypeError(`table ${numero}, regle : 'ou', 'et' ou null attendu, reçu ${JSON.stringify(regle) ?? String(regle)}`); +} + +// La cellule objectif : les étiquettes jointes par JOINTURE_ETIQUETTES, dans +// l'ordre de leur liste ; une liste qui n'est pas de textes lève TypeError. +function celluleObjectif(etiquettes, numero) { + if (!Array.isArray(etiquettes) || !etiquettes.every((motCle) => typeof motCle === 'string')) { + throw new TypeError(`table ${numero}, etiquettes : liste de textes attendue, reçu ${JSON.stringify(etiquettes) ?? String(etiquettes)}`); + } + return etiquettes.join(JOINTURE_ETIQUETTES); +} + +/** + * Les objectifs des tables en CSV (§ 10.4), dans la forme exacte que + * l'import lit : UTF-8 avec marque, « ; », CRLF ; l'en-tête + * table;tour;objectif;regle ; puis une ligne par objectif, les tables par + * numéro affiché puis identifiant croissant, et pour chacune la soirée puis + * les tours croissants. Le tour s'écrit vide pour la soirée ; les étiquettes + * jointes par « , », dans l'ordre de leur liste ; la règle ou, et, ou vide + * quand l'objectif suit le défaut. Une valeur s'écrit telle quelle, même + * quand un tableur la lirait comme une formule (export.js). Sans objectif, + * l'en-tête seul. Les tables reçues ne sont pas modifiées. + * + * @param {Array<{id: number, numero: number, objectifs?: Array<{tour: number|null, regle: 'ou'|'et'|null, etiquettes: string[]}>}>} tables + * @returns {Uint8Array} + * @throws {TypeError} pour une règle hors de ou, et, null, ou des étiquettes + * qui ne sont pas une liste de textes, la table nommée par son numéro + */ +export function exporterObjectifs(tables) { + const lignes = [...tables] + .sort((a, b) => a.numero - b.numero || a.id - b.id) + .flatMap(({ numero, objectifs = [] }) => + [...objectifs] + .sort(ordreDesObjectifs) + .map(({ tour, regle, etiquettes }) => [ + String(numero), + tour === null ? '' : String(tour), + celluleObjectif(etiquettes, numero), + celluleRegle(regle, numero), + ]), + ); + return ecrireCsv([[...CHAMPS_OBJECTIFS], ...lignes], SEPARATEUR); +} diff --git a/src/csv/objectifs.test.js b/src/csv/objectifs.test.js new file mode 100644 index 0000000..e5f962f --- /dev/null +++ b/src/csv/objectifs.test.js @@ -0,0 +1,609 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du CSV des objectifs de table (§ 10.4, A19, A20, A21) : la table +// des en-têtes et son association, l'aperçu et ses refus globaux, les refus +// d'une ligne dans l'ordre de A20, la règle lue dans sa liste fermée, la +// réconciliation des étiquettes, l'import appliqué selon le mode, l'export +// et l'aller-retour, le fichier d'exemple et la boucle de son CSV des refus. +// Les noms, les étiquettes et les événements sont inventés. +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { describe, test } from '../../test/lanceur.js'; +import { serialiser } from '../stockage/canonique.js'; +import { analyser, creerCharge } from '../stockage/document.js'; +import { ETIQUETTES_MAX, ETIQUETTE_LONGUEUR_MAX } from '../stockage/etiquettes.js'; +import { VERSION } from '../version.genere.js'; +import { decoder } from './encodage.js'; +import { ErreurCsv } from './erreurs.js'; +import { JOINTURE_ETIQUETTES } from './export.js'; +import { exporterRefus } from './import.js'; +import { decouper } from './lecture.js'; +import { + CHAMPS_OBJECTIFS, + ENTETES_DES_OBJECTIFS, + MODES_IMPORT_OBJECTIFS, + VALEURS_REGLE, + apercevoirObjectifs, + appliquerImportObjectifs, + associerObjectifs, + bilanRemplacementObjectifs, + exporterObjectifs, + reconnaitreObjectif, +} from './objectifs.js'; + +const EXEMPLES = new URL('../../exemples/', import.meta.url); +const MARQUE = '\u{FEFF}'; +const TAB = '\u{0009}'; + +// Texte d'un CSV : chaque ligne donnée, terminée par CRLF. +const csv = (...lignes) => lignes.map((ligne) => `${ligne}\r\n`).join(''); + +// Texte des octets, la marque d'ordre d'octets gardée en tête. +const texteDe = (octets) => new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(octets); + +const personne = (id, nom, prenom, autres = {}) => ({ + id, nom, prenom, appartenance: null, courriel: null, titrePressenti: null, notes: null, exclu: false, ...autres, +}); + +const table = (id, numero, autres = {}) => ({ id, numero, sieges: null, forme: 'ronde', position: { x: 200 * id, y: 0 }, ...autres }); + +// Un événement inventé de quatre tours : trois personnes, l'une exclue, et +// trois tables numérotées 1, 2 et 3, sans objectif. +function evenement() { + const charge = creerCharge({ id: 'evt-herons', nom: 'Soirée des Hérons', siegesParDefaut: 4, tours: 4 }); + charge.prochainsIds = { participant: 4, table: 4, proposition: 1 }; + charge.participants = [ + personne(1, 'Ombrelle', 'Iris', { etiquettes: ['finance'] }), + personne(2, 'Pervenche', 'Théo', { etiquettes: ['Montréal', 'bénévole'] }), + personne(3, 'Lacasse', 'Ondine', { exclu: true, etiquettes: ['ressources humaines'] }), + ]; + charge.tables = [table(1, 1), table(2, 2), table(3, 3)]; + return charge; +} + +// L'événement dont les tables portent des objectifs : la table 1 un +// objectif de soirée et un du tour 2, la table 3 un du tour 3, règle propre. +function avecObjectifs() { + const charge = evenement(); + charge.tables[0].objectifs = [ + { portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }, + { portee: 'tour', tour: 2, regle: 'et', etiquettes: ['Montréal', 'bénévole'] }, + ]; + charge.tables[2].objectifs = [{ portee: 'tour', tour: 3, regle: 'ou', etiquettes: ['Vélo'] }]; + return charge; +} + +// La charge relue par le schéma du fichier d'état : elle s'écrit et se relit. +const relue = (charge) => analyser(serialiser(charge, { revision: 1, produitVersion: VERSION.affichee })).charge; + +// Les codes des lignes refusées, avec leur rang. +const refus = (apercu) => apercu.refusees.map(({ ligne, code }) => [ligne, code]); + +// Le refus d'une seule ligne de données sous l'en-tête donné, sans brut. +function refusDe(ligne, { entete = 'table;tour;objectif;regle', charge = evenement() } = {}) { + const apercu = apercevoirObjectifs(csv(entete, ligne), charge); + assert.equal(apercu.refusGlobal, 'AUCUNE_LIGNE_VALIDE', ligne); + assert.equal(apercu.refusees.length, 1, ligne); + const [{ brut, ...reste }] = apercu.refusees; + assert.ok(Array.isArray(brut)); + return reste; +} + +// Les champs lus d'une seule ligne valide. +function champsDe(ligne, { entete = 'table;tour;objectif;regle', charge = evenement(), mode } = {}) { + const apercu = apercevoirObjectifs(csv(entete, ligne), charge, { mode }); + assert.equal(apercu.refusGlobal, null, ligne); + assert.deepEqual(apercu.refusees, [], ligne); + return apercu.lignes[0].champs; +} + +const lireExemple = () => decoder(readFileSync(new URL('objectifs_cas_limites.csv', EXEMPLES))).texte; + +describe('objectifs : la table des en-têtes (§ 10.4)', () => { + test('quatre colonnes, dans l’ordre de l’export : table, tour, objectif, regle, figées à toute profondeur', () => { + assert.deepEqual(CHAMPS_OBJECTIFS, ['table', 'tour', 'objectif', 'regle']); + assert.ok(Object.isFrozen(CHAMPS_OBJECTIFS)); + assert.deepEqual( + ENTETES_DES_OBJECTIFS.map(([champ, entetes]) => [champ, [...entetes]]), + [ + ['table', ['table']], + ['tour', ['tour']], + ['objectif', ['objectif', 'objectifs']], + ['regle', ['regle', 'règle']], + ], + ); + assert.ok(Object.isFrozen(ENTETES_DES_OBJECTIFS)); + for (const paire of ENTETES_DES_OBJECTIFS) { + assert.ok(Object.isFrozen(paire) && Object.isFrozen(paire[1]), paire[0]); + } + }); + + test('chaque en-tête sans égard à la casse, aux accents ni aux espaces ; ceux des participants, ligne et motif restent inconnus', () => { + assert.deepEqual( + ['Table', 'TOUR', 'Objectif', 'objectifs', 'Règle', 'REGLE', ' r è g l e '].map(reconnaitreObjectif), + ['table', 'tour', 'objectif', 'objectif', 'regle', 'regle', 'regle'], + ); + assert.deepEqual( + ['nom', 'etiquettes', 'étiquette', 'ligne', 'motif', 'numero', 'tables', ''].map(reconnaitreObjectif), + [null, null, null, null, null, null, null, null], + ); + }); + + test('objectif et objectifs ensemble : une ambiguïté, que le choix tranche', () => { + assert.deepEqual(associerObjectifs(['table', 'objectif', 'objectifs']), { + colonnes: { table: 0 }, + ambigus: [{ champ: 'objectif', rangs: [1, 2] }], + nonReconnues: [], + }); + assert.deepEqual(associerObjectifs(['Règle', 'objectifs', 'Table', 'notes']), { + colonnes: { table: 2, objectif: 1, regle: 0 }, + ambigus: [], + nonReconnues: [3], + }); + }); + + test('la règle, liste fermée : ou, une, au moins une ; et, toutes ; vide suit le défaut', () => { + assert.deepEqual([...VALEURS_REGLE], [ + ['ou', 'ou'], + ['une', 'ou'], + ['au moins une', 'ou'], + ['et', 'et'], + ['toutes', 'et'], + ['', null], + ]); + assert.deepEqual([...MODES_IMPORT_OBJECTIFS], ['mettreAJour', 'remplacer']); + }); +}); + +describe('objectifs : les refus globaux de l’aperçu (§ 10.1, § 10.4)', () => { + test('les refus du § 10.1 hors NOM_NON_ASSOCIE, chacun sans ligne ni fusion, le mode gardé', () => { + const cas = [ + ['table;objectif\r\n1;fin\u{0000}ance\r\n', 'CARACTERE_NUL', null], + ['', 'AUCUN_ENTETE', null], + [csv('salle;teinte', '1;bleu'), 'AUCUN_ENTETE', null], + [csv('nom;prenom', 'Ombrelle;Iris'), 'AUCUN_ENTETE', null], + [csv('table;objectif', '1;finance;bénévole', '2;Montréal'), 'SEPARATEUR_INTROUVABLE', 2], + [csv('table;objectif', '1;"finance'), 'GUILLEMET_OUVERT', 2], + [csv('table;objectif', '9;finance'), 'AUCUNE_LIGNE_VALIDE', null], + ]; + for (const [texte, code, ligne] of cas) { + const apercu = apercevoirObjectifs(texte, evenement(), { mode: 'remplacer' }); + assert.equal(apercu.refusGlobal, code, JSON.stringify(texte)); + assert.equal(apercu.refusGlobalLigne, ligne, code); + assert.equal(apercu.mode, 'remplacer'); + for (const cle of ['lignes', 'fusionsEtiquettes', 'multilignes']) assert.deepEqual(apercu[cle], [], `${code} ${cle}`); + if (code !== 'AUCUNE_LIGNE_VALIDE') assert.deepEqual(apercu.refusees, [], code); + } + }); + + test('aucune colonne table : TABLE_NON_ASSOCIEE ; aucune colonne objectif : OBJECTIF_NON_ASSOCIE ; la première manque d’abord', () => { + const lire = (...lignes) => apercevoirObjectifs(csv(...lignes), evenement()); + assert.equal(lire('tour;objectif', '2;finance').refusGlobal, 'TABLE_NON_ASSOCIEE'); + assert.equal(lire('table;tour', '1;2').refusGlobal, 'OBJECTIF_NON_ASSOCIE'); + assert.equal(lire('tour;regle', '2;ou').refusGlobal, 'TABLE_NON_ASSOCIEE'); + // Une colonne ambiguë n'est associée à rien : elle manque. + const ambigu = lire('table;objectif;objectifs', '1;finance;bénévole'); + assert.equal(ambigu.refusGlobal, 'OBJECTIF_NON_ASSOCIE'); + assert.deepEqual(ambigu.association.ambigus, [{ champ: 'objectif', rangs: [1, 2] }]); + assert.deepEqual(ambigu.entetes, ['table', 'objectif', 'objectifs']); + const tranche = apercevoirObjectifs(csv('table;objectif;objectifs', '1;finance;bénévole'), evenement(), { choix: { objectif: 2 } }); + assert.equal(tranche.refusGlobal, null); + assert.deepEqual(tranche.lignes[0].champs.etiquettes, ['bénévole']); + }); + + test('un événement sans table : OBJECTIFS_SANS_TABLE, après les en-têtes ; un fichier sans colonne table le dit d’abord', () => { + const vide = { ...evenement(), tables: [] }; + const apercu = apercevoirObjectifs(csv('table;objectif', '1;finance'), vide); + assert.equal(apercu.refusGlobal, 'OBJECTIFS_SANS_TABLE'); + assert.equal(apercu.separateur, ';'); + assert.deepEqual(apercu.association.colonnes, { table: 0, objectif: 1 }); + assert.deepEqual(apercu.refusees, []); + assert.equal(apercevoirObjectifs(csv('tour;objectif', '1;finance'), vide).refusGlobal, 'TABLE_NON_ASSOCIEE'); + }); + + test('un fichier de participants lu comme objectifs ne passe jamais pour des objectifs', () => { + const participants = csv('nom;prenom;table', 'Ombrelle;Iris;2'); + assert.equal(apercevoirObjectifs(participants, evenement()).refusGlobal, 'OBJECTIF_NON_ASSOCIE'); + }); + + test('un texte qui n’est pas une chaîne, un mode inconnu, ajouter compris, et une charge sans tours lèvent TypeError', () => { + assert.throws(() => apercevoirObjectifs(new Uint8Array([1]), evenement()), TypeError); + assert.throws(() => apercevoirObjectifs(csv('table;objectif', '1;finance'), evenement(), { mode: 'ajouter' }), TypeError); + const sansTours = { ...evenement(), evenement: { ...evenement().evenement, tours: 0 } }; + assert.throws(() => apercevoirObjectifs(csv('table;objectif', '1;finance'), sansTours), TypeError); + }); +}); + +describe('objectifs : les refus d’une ligne, un motif, dans l’ordre de A20 (§ 10.4)', () => { + test('le numéro de table : absent, non entier, inconnu, ou porté par deux tables', () => { + assert.deepEqual(refusDe(';;finance;'), { ligne: 2, code: 'TABLE_ABSENTE', valeur: null }); + for (const valeur of ['2.0', 'deux', '3b', '1 2', '+', 'Ⅱ']) { + assert.deepEqual(refusDe(`${valeur};;finance;`), { ligne: 2, code: 'TABLE_NON_ENTIERE', valeur }, valeur); + } + assert.deepEqual(refusDe(' 9 ;;finance;'), { ligne: 2, code: 'NUMERO_TABLE_INCONNU', valeur: '9' }); + assert.deepEqual(refusDe('0;;finance;'), { ligne: 2, code: 'NUMERO_TABLE_INCONNU', valeur: '0' }); + const deuxTrois = evenement(); + deuxTrois.tables.push(table(4, 3)); + assert.deepEqual(refusDe('3;;finance;', { charge: deuxTrois }), { ligne: 2, code: 'PLUSIEURS_TABLES', valeur: '3' }); + // Le numéro affiché, jamais l'identifiant : la table d'identifiant 7 + // porte le numéro 2. + const renumerotee = evenement(); + renumerotee.tables = [table(7, 2)]; + assert.deepEqual(champsDe('2;;finance;', { charge: renumerotee }), { + numero: 2, table: 7, tour: null, regle: null, etiquettes: ['finance'], + }); + assert.deepEqual(refusDe('7;;finance;', { charge: renumerotee }), { ligne: 2, code: 'NUMERO_TABLE_INCONNU', valeur: '7' }); + assert.equal(champsDe('02;;finance;').table, 2); + }); + + test('le tour : vide pour la soirée, non entier, ou hors de la soirée, qui nomme le nombre de tours', () => { + assert.equal(champsDe('1;;finance;').tour, null); + assert.equal(champsDe('1; 4 ;finance;').tour, 4); + for (const valeur of ['2.0', 'deux', 'premier', '1,5']) { + const entete = 'table\u{0009}tour\u{0009}objectif\u{0009}regle'; + const ligne = ['1', valeur, 'finance', ''].join(TAB); + assert.deepEqual(refusDe(ligne, { entete }), { ligne: 2, code: 'TOUR_NON_ENTIER', valeur }, valeur); + } + for (const valeur of ['0', '5', '9', '-1']) { + assert.deepEqual(refusDe(`1;${valeur};finance;`), { ligne: 2, code: 'TOUR_HORS_SOIREE', valeur, tours: 4 }, valeur); + } + }); + + test("l'objectif : vide ou fait de séparateurs, puis une étiquette mal formée, puis une vingt-et-unième étiquette", () => { + assert.deepEqual(refusDe('1;;;'), { ligne: 2, code: 'OBJECTIF_VIDE', valeur: null }); + assert.deepEqual(refusDe('1;;" , | ";'), { ligne: 2, code: 'OBJECTIF_VIDE', valeur: null }); + assert.deepEqual(refusDe(`1;;finance, ressources${TAB}humaines;`), { + ligne: 2, code: 'ETIQUETTE_MAL_FORMEE', valeur: `ressources${TAB}humaines`, raison: 'controle', + }); + const longue = 'x'.repeat(ETIQUETTE_LONGUEUR_MAX + 1); + assert.deepEqual(refusDe(`1;;${longue};`), { ligne: 2, code: 'ETIQUETTE_MAL_FORMEE', valeur: longue, raison: 'longueur' }); + const vingtEtUne = Array.from({ length: ETIQUETTES_MAX + 1 }, (_, i) => `mot${i}`).join('|'); + assert.deepEqual(refusDe(`1;;${vingtEtUne};`), { + ligne: 2, code: 'ETIQUETTES_TROP_NOMBREUSES', valeur: null, nombre: ETIQUETTES_MAX + 1, + }); + // Vingt clés, dont une sous deux orthographes : une seule étiquette. + const vingt = [...Array.from({ length: ETIQUETTES_MAX }, (_, i) => `mot${i}`), 'MOT0'].join('|'); + assert.equal(champsDe(`1;;${vingt};`).etiquettes.length, ETIQUETTES_MAX); + }); + + test("la règle, sans égard à la casse, aux accents ni aux espaces ; l'inconnu refuse, jamais lu comme le défaut", () => { + const lues = [ + ['', null], ['ou', 'ou'], ['OU', 'ou'], [' Une ', 'ou'], ['Au moins une', 'ou'], ['âu moins ÜNE', 'ou'], + ['et', 'et'], ['ET', 'et'], ['Toutes', 'et'], ['TOUTÈS', 'et'], + ]; + for (const [valeur, regle] of lues) assert.equal(champsDe(`1;;finance;${valeur}`).regle, regle, valeur); + for (const valeur of ['peut-être', 'TOUTES LES', 'oui', 'non', 'aumoinsune', '0', '1']) { + assert.deepEqual(refusDe(`1;;finance;${valeur}`), { ligne: 2, code: 'REGLE_INCONNUE', valeur }, valeur); + } + // Une colonne règle absente : chaque objectif suit le défaut. + assert.equal(champsDe('1;finance', { entete: 'table;objectif' }).regle, null); + }); + + test("un même couple (table, tour) une deuxième fois : OBJECTIF_EN_DOUBLE, qui nomme la première ligne ; une ligne refusée n'en est pas une première", () => { + const apercu = apercevoirObjectifs( + csv( + 'table;tour;objectif;regle', + '1;;finance;', + '1;2;Montréal;', + '1;;bénévole;', + '2;2;finance;peut-être', + '2;2;bénévole;', + '1;2;finance;et', + '01;;Montréal;', + ), + evenement(), + ); + assert.deepEqual(refus(apercu), [ + [4, 'OBJECTIF_EN_DOUBLE'], + [5, 'REGLE_INCONNUE'], + [7, 'OBJECTIF_EN_DOUBLE'], + [8, 'OBJECTIF_EN_DOUBLE'], + ]); + const details = apercu.refusees.filter(({ code }) => code === 'OBJECTIF_EN_DOUBLE').map(({ brut, ligne, code, ...reste }) => reste); + assert.deepEqual(details, [ + { valeur: null, premiereLigne: 2, numero: 1, tour: null }, + { valeur: null, premiereLigne: 3, numero: 1, tour: 2 }, + { valeur: null, premiereLigne: 2, numero: 1, tour: null }, + ]); + assert.deepEqual(apercu.lignes.map(({ ligne }) => ligne), [2, 3, 6]); + }); + + test("un champ en trop passe avant tout ; puis chaque ligne garde son premier motif, dans l'ordre de A20", () => { + const apercu = apercevoirObjectifs( + csv( + 'table;tour;objectif;regle', + '1;2;finance;ou', + '9;9;;peut-être', + '1;9;;peut-être', + '1;2;;peut-être', + `1;2;fin${TAB}ance;peut-être`, + '1;2;finance;peut-être', + '1;2;finance;ou', + '"1;2";;finance;ou', + ), + evenement(), + ); + assert.deepEqual(refus(apercu), [ + [3, 'NUMERO_TABLE_INCONNU'], + [4, 'TOUR_HORS_SOIREE'], + [5, 'OBJECTIF_VIDE'], + [6, 'ETIQUETTE_MAL_FORMEE'], + [7, 'REGLE_INCONNUE'], + [8, 'OBJECTIF_EN_DOUBLE'], + [9, 'TABLE_NON_ENTIERE'], + ]); + const enTrop = apercevoirObjectifs( + csv('table;objectif', '1;finance', '2;bénévole', ...Array(20).fill('3;Montréal'), '9;;x'), + evenement(), + ); + assert.deepEqual(refus(enTrop).at(-1), [24, 'CHAMPS_EN_TROP']); + }); + + test('brut garde les champs tels que découpés ; ligne compte l’en-tête, et une cellule sur deux lignes une fois', () => { + const apercu = apercevoirObjectifs( + csv('Table;Tour;Objectif;Règle', '1;;"finance', 'bénévole";', ' 9 ; ;Montréal; et '), + evenement(), + ); + assert.deepEqual(apercu.refusees, [ + { ligne: 3, code: 'NUMERO_TABLE_INCONNU', valeur: '9', brut: [' 9 ', ' ', 'Montréal', ' et '] }, + ]); + assert.deepEqual(apercu.lignes, [ + { + ligne: 2, + champs: { numero: 1, table: 1, tour: null, regle: null, etiquettes: ['finance', 'bénévole'] }, + brut: ['1', '', 'finance\r\nbénévole', ''], + }, + ]); + assert.deepEqual(apercu.multilignes, []); + }); + + test("une cellule sur plusieurs lignes qui porte le séparateur se nomme : un guillemet égaré y fond deux lignes", () => { + const apercu = apercevoirObjectifs(csv('table;tour;objectif;regle', '1;;"finance', '2;;bénévole";'), evenement()); + assert.equal(apercu.refusGlobal, null); + assert.deepEqual(apercu.multilignes, [{ ligne: 2, rangs: [2] }]); + }); +}); + +describe('objectifs : les étiquettes se réconcilient avec celles de l’événement (§ 10.1, § 10.4)', () => { + test('mettre à jour : les personnes, exclues comprises, et les objectifs donnent leur orthographe, et le rapport nomme chaque fusion', () => { + const apercu = apercevoirObjectifs( + csv('table;tour;objectif', '2;;FINANCE', '3;;vélo, Ressources Humaines', '1;;MONTREAL', '1;3;Bénévole, bénévole'), + avecObjectifs(), + ); + assert.deepEqual( + apercu.lignes.map(({ champs }) => champs.etiquettes), + [['finance'], ['Vélo', 'ressources humaines'], ['Montréal'], ['bénévole']], + ); + assert.deepEqual(apercu.fusionsEtiquettes, [ + { retenue: 'finance', orthographes: ['FINANCE', 'finance'], lignes: 1 }, + { retenue: 'Vélo', orthographes: ['Vélo', 'vélo'], lignes: 1 }, + { retenue: 'ressources humaines', orthographes: ['Ressources Humaines', 'ressources humaines'], lignes: 1 }, + { retenue: 'Montréal', orthographes: ['MONTREAL', 'Montréal'], lignes: 1 }, + { retenue: 'bénévole', orthographes: ['Bénévole', 'bénévole'], lignes: 1 }, + ]); + }); + + test('remplacer : les objectifs partent, leurs orthographes avec eux ; celles des personnes restent', () => { + const apercu = apercevoirObjectifs(csv('table;objectif', '3;vélo', '2;FINANCE'), avecObjectifs(), { mode: 'remplacer' }); + assert.deepEqual(apercu.lignes.map(({ champs }) => champs.etiquettes), [['vélo'], ['finance']]); + assert.deepEqual(apercu.fusionsEtiquettes, [{ retenue: 'finance', orthographes: ['FINANCE', 'finance'], lignes: 1 }]); + assert.equal(apercu.mode, 'remplacer'); + }); +}); + +describe('objectifs : ce que remplacer touche, et l’import appliqué (§ 10.4)', () => { + test('bilanRemplacementObjectifs compte les objectifs de l’événement et les tables qui en portent', () => { + assert.deepEqual(bilanRemplacementObjectifs(avecObjectifs()), { objectifs: 3, tables: 2 }); + assert.deepEqual(bilanRemplacementObjectifs(evenement()), { objectifs: 0, tables: 0 }); + }); + + test('mettre à jour : chaque couple du fichier remplace le sien, les autres restent ; la soirée avant les tours, les étiquettes rangées', () => { + const charge = avecObjectifs(); + const copie = structuredClone(charge); + const apercu = apercevoirObjectifs( + csv('table;tour;objectif;regle', '1;;finance;', '1;2;finance, Montréal;toutes', '2;3;Vélo;', '2;;bénévole;ou'), + charge, + ); + const { charge: apres, resume } = appliquerImportObjectifs(charge, apercu, 'mettreAJour'); + assert.deepEqual(charge, copie); + assert.deepEqual(apres.tables[0].objectifs, [ + { portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }, + { portee: 'tour', tour: 2, regle: 'et', etiquettes: ['Montréal', 'finance'] }, + ]); + assert.deepEqual(apres.tables[1].objectifs, [ + { portee: 'tous', tour: null, regle: 'ou', etiquettes: ['bénévole'] }, + { portee: 'tour', tour: 3, regle: null, etiquettes: ['Vélo'] }, + ]); + assert.deepEqual(apres.tables[2].objectifs, charge.tables[2].objectifs); + assert.deepEqual(resume, { ajoutes: 2, remplaces: 1, inchanges: 1, retires: 0, tables: 2, refusees: [], derive: false }); + assert.deepEqual(relue(apres), apres); + for (const { objectifs = [] } of apres.tables) { + for (const objectif of objectifs) assert.ok(!charge.tables.some((t) => t.objectifs?.includes(objectif))); + } + }); + + test('remplacer : chaque objectif part, puis ceux du fichier s’écrivent ; une table qui n’en garde aucun perd son champ', () => { + const charge = avecObjectifs(); + const apercu = apercevoirObjectifs(csv('table;tour;objectif', '2;;finance', '1;;finance'), charge, { mode: 'remplacer' }); + const { charge: apres, resume } = appliquerImportObjectifs(charge, apercu, 'remplacer'); + assert.deepEqual(apres.tables[0].objectifs, [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }]); + assert.deepEqual(apres.tables[1].objectifs, [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }]); + assert.ok(!Object.hasOwn(apres.tables[2], 'objectifs')); + assert.deepEqual(resume, { ajoutes: 1, remplaces: 0, inchanges: 1, retires: 2, tables: 3, refusees: [], derive: false }); + assert.deepEqual(relue(apres), apres); + assert.deepEqual(apres.participants, charge.participants); + }); + + test('les refus de l’aperçu passent au résumé, copiés ; derive en état retenu ; un réimport identique ne change rien', () => { + const charge = avecObjectifs(); + charge.evenement.etat = 'retenu'; + const apercu = apercevoirObjectifs(csv('table;tour;objectif', '1;;finance', '9;;finance'), charge); + const { charge: apres, resume } = appliquerImportObjectifs(charge, apercu, 'mettreAJour'); + assert.deepEqual(apres, charge); + assert.equal(resume.derive, true); + assert.deepEqual(resume.inchanges, 1); + assert.deepEqual(resume.refusees, apercu.refusees); + assert.notEqual(resume.refusees[0], apercu.refusees[0]); + }); + + test('refus : en bloqué, puis le refus global, puis l’ambiguïté ; un mode inconnu ou autre que celui de l’aperçu lève TypeError', () => { + const charge = evenement(); + const leve = (appel, code, details) => + assert.throws(appel, (erreur) => erreur instanceof ErreurCsv && erreur.code === code && JSON.stringify(erreur.details) === JSON.stringify(details)); + const valide = apercevoirObjectifs(csv('table;objectif', '1;finance'), charge); + const bloquee = { ...charge, evenement: { ...charge.evenement, etat: 'bloque' } }; + leve(() => appliquerImportObjectifs(bloquee, valide, 'mettreAJour'), 'PLAN_BLOQUE', {}); + const guillemet = apercevoirObjectifs(csv('table;objectif', '1;"finance'), charge); + leve(() => appliquerImportObjectifs(charge, guillemet, 'mettreAJour'), 'GUILLEMET_OUVERT', { ligne: 2 }); + const sansTable = apercevoirObjectifs(csv('table;objectif', '1;finance'), { ...charge, tables: [] }); + leve(() => appliquerImportObjectifs(charge, sansTable, 'mettreAJour'), 'OBJECTIFS_SANS_TABLE', { ligne: null }); + const ambigu = apercevoirObjectifs(csv('table;objectif;règle;regle', '1;finance;ou;et'), charge); + leve(() => appliquerImportObjectifs(charge, ambigu, 'mettreAJour'), 'AMBIGUITE', { champs: ['regle'] }); + assert.throws(() => appliquerImportObjectifs(charge, valide, 'remplacer'), TypeError); + assert.throws(() => appliquerImportObjectifs(charge, valide, 'ajouter'), TypeError); + }); +}); + +describe('objectifs : l’export, gabarit de l’import et sauvegarde (§ 10.4)', () => { + test('table;tour;objectif;regle, les tables par numéro puis identifiant, la soirée puis les tours, la règle ou, et ou vide', () => { + const charge = avecObjectifs(); + // Une quatrième table porte le numéro 1, comme la première : deux tables + // d'un même numéro se rangent par identifiant. + charge.tables.push(table(4, 1, { objectifs: [{ portee: 'tous', tour: null, regle: 'et', etiquettes: ['=SOMME(A1)', 'finance'] }] })); + charge.tables.reverse(); + const octets = exporterObjectifs(charge.tables); + assert.equal( + texteDe(octets), + MARQUE + + csv( + 'table;tour;objectif;regle', + '1;;finance;', + '1;2;Montréal, bénévole;et', + '1;;=SOMME(A1), finance;et', + '3;3;Vélo;ou', + ), + ); + assert.equal(JOINTURE_ETIQUETTES, ', '); + assert.equal(texteDe(exporterObjectifs(evenement().tables)), `${MARQUE}table;tour;objectif;regle\r\n`); + }); + + test('l’aller-retour : exporté, réimporté en remplacement sur l’événement sans objectifs, les mêmes objectifs ; réimporté en mise à jour, rien ne change', () => { + const charge = avecObjectifs(); + charge.tables[1].objectifs = [{ portee: 'tour', tour: 4, regle: null, etiquettes: ['finance', 'ressources humaines'] }]; + const texte = decoder(exporterObjectifs(charge.tables)).texte; + const vide = evenement(); + const apercu = apercevoirObjectifs(texte, vide, { mode: 'remplacer' }); + assert.equal(apercu.separateur, ';'); + assert.deepEqual(apercu.refusees, []); + assert.deepEqual(appliquerImportObjectifs(vide, apercu, 'remplacer').charge.tables, charge.tables); + const meme = appliquerImportObjectifs(charge, apercevoirObjectifs(texte, charge), 'mettreAJour'); + assert.deepEqual(meme.charge, charge); + assert.deepEqual([meme.resume.ajoutes, meme.resume.remplaces, meme.resume.inchanges, meme.resume.tables], [0, 0, 4, 0]); + }); + + test('une forme fausse lève TypeError, qui nomme la table', () => { + const charge = evenement(); + charge.tables[1].objectifs = [{ portee: 'tous', tour: null, regle: 'toutes', etiquettes: ['finance'] }]; + assert.throws(() => exporterObjectifs(charge.tables), (erreur) => erreur instanceof TypeError && /table 2/.test(erreur.message)); + charge.tables[1].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: 'finance' }]; + assert.throws(() => exporterObjectifs(charge.tables), TypeError); + }); +}); + +describe('objectifs : le fichier d’exemple et la boucle de son CSV des refus (A19, § 10.3, § 14.10)', () => { + test('objectifs_cas_limites.csv : UTF-8 avec marque, « ; », CRLF, l’en-tête Table;Tour;Objectif;Règle, et le contenu fixé', () => { + const octets = readFileSync(new URL('objectifs_cas_limites.csv', EXEMPLES)); + assert.deepEqual([...octets.subarray(0, 3)], [0xef, 0xbb, 0xbf]); + assert.equal( + texteDe(octets), + MARQUE + + csv( + 'Table;Tour;Objectif;Règle', + '1;;finance, bénévole;', + '1;2;Montréal;toutes', + '2;;FINANCE;au moins une', + '9;;finance;', + '2;9;finance;', + '2;;ressources humaines;peut-être', + '1;2;finance;', + ), + ); + }); + + test("sur un événement à deux tables et quatre tours : trois objectifs, dont une soirée et un tour sur la table 1 et une orthographe fondue ; quatre refus, un par motif", () => { + const charge = evenement(); + charge.tables = charge.tables.slice(0, 2); + const apercu = apercevoirObjectifs(lireExemple(), charge); + assert.equal(apercu.refusGlobal, null); + assert.equal(apercu.separateur, ';'); + assert.deepEqual(apercu.lignes.map(({ ligne, champs }) => [ligne, champs]), [ + [2, { numero: 1, table: 1, tour: null, regle: null, etiquettes: ['finance', 'bénévole'] }], + [3, { numero: 1, table: 1, tour: 2, regle: 'et', etiquettes: ['Montréal'] }], + [4, { numero: 2, table: 2, tour: null, regle: 'ou', etiquettes: ['finance'] }], + ]); + assert.deepEqual(apercu.fusionsEtiquettes, [{ retenue: 'finance', orthographes: ['FINANCE', 'finance'], lignes: 1 }]); + assert.deepEqual(apercu.refusees.map(({ brut, ...reste }) => reste), [ + { ligne: 5, code: 'NUMERO_TABLE_INCONNU', valeur: '9' }, + { ligne: 6, code: 'TOUR_HORS_SOIREE', valeur: '9', tours: 4 }, + { ligne: 7, code: 'REGLE_INCONNUE', valeur: 'peut-être' }, + { ligne: 8, code: 'OBJECTIF_EN_DOUBLE', valeur: null, premiereLigne: 3, numero: 1, tour: 2 }, + ]); + }); + + test('l’aller-retour de l’exemple : appliqué en mise à jour, exporté, réimporté en remplacement sur l’événement sans objectifs, les mêmes tables', () => { + const deuxTables = () => { + const charge = evenement(); + charge.tables = charge.tables.slice(0, 2); + return charge; + }; + const apres = appliquerImportObjectifs(deuxTables(), apercevoirObjectifs(lireExemple(), deuxTables()), 'mettreAJour').charge; + // L'orthographe fondue s'écrit « finance », « au moins une » s'écrit + // « ou », « toutes » s'écrit « et » : ce sont ces formes que l'export + // doit rendre au réimport. + assert.deepEqual(apres.tables.map(({ objectifs }) => objectifs), [ + [ + { portee: 'tous', tour: null, regle: null, etiquettes: ['bénévole', 'finance'] }, + { portee: 'tour', tour: 2, regle: 'et', etiquettes: ['Montréal'] }, + ], + [{ portee: 'tous', tour: null, regle: 'ou', etiquettes: ['finance'] }], + ]); + const texte = decoder(exporterObjectifs(apres.tables)).texte; + const vide = deuxTables(); + const apercu = apercevoirObjectifs(texte, vide, { mode: 'remplacer' }); + assert.deepEqual(apercu.refusees, []); + assert.deepEqual(appliquerImportObjectifs(vide, apercu, 'remplacer').charge.tables, apres.tables); + }); + + test('le CSV des refus se réimporte tel quel : une cellule corrigée fait entrer sa ligne, les autres reviennent pour le même motif', () => { + const charge = evenement(); + charge.tables = charge.tables.slice(0, 2); + const apercu = apercevoirObjectifs(lireExemple(), charge); + const motif = (code, { ligne }) => `${code} à la ligne ${ligne}`; + const refusTexte = decoder(exporterRefus(apercu, motif)).texte; + const { enregistrements } = decouper(refusTexte, ';'); + assert.deepEqual(enregistrements[0], ['Table', 'Tour', 'Objectif', 'Règle', 'ligne', 'motif']); + assert.deepEqual(enregistrements.slice(1).map((e) => e.slice(0, 4)), [ + ['9', '', 'finance', ''], + ['2', '9', 'finance', ''], + ['2', '', 'ressources humaines', 'peut-être'], + ['1', '2', 'finance', ''], + ]); + // Réimporté sans correction, sur l'événement d'avant : chaque ligne + // revient pour le même motif, sauf le doublon, que plus rien ne précède + // dans le fichier des refus. + const tel = apercevoirObjectifs(refusTexte, charge); + assert.deepEqual(refus(tel), [[2, 'NUMERO_TABLE_INCONNU'], [3, 'TOUR_HORS_SOIREE'], [4, 'REGLE_INCONNUE']]); + assert.deepEqual(tel.lignes.map(({ ligne }) => ligne), [5]); + // La règle corrigée dans sa cellule : la ligne entre, les autres restent. + const corrige = refusTexte.replace('ressources humaines;peut-être;', 'ressources humaines;toutes;'); + assert.notEqual(corrige, refusTexte); + const reimporte = apercevoirObjectifs(corrige, charge); + assert.deepEqual(refus(reimporte), [[2, 'NUMERO_TABLE_INCONNU'], [3, 'TOUR_HORS_SOIREE']]); + assert.deepEqual(reimporte.lignes.map(({ ligne, champs }) => [ligne, champs.numero, champs.tour, champs.regle]), [ + [4, 2, null, 'et'], + [5, 1, 2, null], + ]); + assert.deepEqual(reimporte.association.nonReconnues, [4, 5]); + }); +}); diff --git a/src/interface/libelles/communs.js b/src/interface/libelles/communs.js index f62b653..c981f71 100644 --- a/src/interface/libelles/communs.js +++ b/src/interface/libelles/communs.js @@ -59,6 +59,7 @@ export const TABLE = new Map([ ['communs.remede.reintegrerParticipant', 'Réintégrer la personne'], ['communs.remede.supprimerParticipant', 'Supprimer la personne'], ['communs.remede.importerParticipants', 'Importer la liste'], + ['communs.remede.importerObjectifs', 'Importer les objectifs'], ['communs.remede.changerEtat', "Changer l'état du plan"], ['communs.remede.enregistrerGeneration', 'Enregistrer la génération'], ['communs.remede.retenirProposition', 'Retenir la proposition'], @@ -81,6 +82,8 @@ export const TABLE = new Map([ ['communs.remede.poserObjectif', "Poser l'objectif"], ['communs.remede.retirerObjectif', "Retirer l'objectif"], ['communs.remede.changerRegleObjectifs', 'Changer la règle par défaut des objectifs'], + ['communs.remede.changerPriorites', "Changer l'ordre des priorités"], + ['communs.remede.retablirPriorites', "Rétablir l'ordre par défaut"], ['communs.remede.reserver', 'Réserver la place'], ['communs.remede.retirerReservation', 'Retirer la réservation'], ['communs.remede.deplacerReservation', 'Déplacer la réservation'], diff --git a/src/interface/libelles/propositions.js b/src/interface/libelles/propositions.js index cf3f6ba..f72d5e8 100644 --- a/src/interface/libelles/propositions.js +++ b/src/interface/libelles/propositions.js @@ -15,6 +15,14 @@ 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. +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(', '); + export const TABLE = new Map([ // --- La section (§ 5.7) ------------------------------------------------------ ['propositions.tableau.titre', 'Propositions classées'], @@ -34,11 +42,15 @@ export const TABLE = new Map([ [...criteres.map((nom) => critere(nom, texte)), texte('propositions.ordre.identifiant')].join(texte('propositions.ordre.separateur')) + 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. [ 'propositions.ordre.saute', ({ saute, applique }, texte) => - `Critère sauté, faute de plafond a priori par personne : ${critere(saute, texte)}. ` + - `Appliqué à sa place : ${critere(applique, texte)}.`, + `Critère sauté, faute de plafond a priori par personne : ${critere(saute, texte)}.` + + (applique === null ? '' : ` Appliqué à sa place : ${palier(applique, texte)}.`), ], // --- Les pages du classement (§ 5.7) ------------------------------------------- diff --git a/src/interface/propositions/modele.js b/src/interface/propositions/modele.js index b063891..e7b43fb 100644 --- a/src/interface/propositions/modele.js +++ b/src/interface/propositions/modele.js @@ -212,13 +212,17 @@ function ligneDe(ligne, table, idRetenu, rang = null) { } // La colonne que désigne chaque critère du classement (§ 5.7) : celle qui -// porte la grandeur qu'il compare. La Map ne sert qu'à retrouver la colonne -// d'un critère ; la phrase suit l'ordre que le classement rend. +// 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([ ['ecartAuPlafondAPrioriMax', 'ecartAuPlafondAPriori'], ['excedentCollisions', 'excedentCollisions'], ['collisionsCumulees', 'collisionsCumulees'], + ['toursHorsObjectif', null], ['rencontresRepetees', 'rencontresRepetees'], + ['animateursNonRencontres', null], ['redondance', 'totalRedondance'], ['identifiant', 'proposition'], ]); @@ -226,11 +230,13 @@ const COLONNE_DU_CRITERE = new Map([ /** * 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. Lève TypeError pour un critère sans colonne : un critère - * ajouté au classement doit dire laquelle il compare. + * 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. * - * @param {string} critere un nom de CRITERES (moteur), ou 'identifiant' - * @returns {string} le nom d'une colonne de COLONNES + * @param {string} critere un nom de CRITERES (criteres.js), ou 'identifiant' + * @returns {string|null} le nom d'une colonne de COLONNES, ou null */ export function colonneDuCritere(critere) { const colonne = COLONNE_DU_CRITERE.get(critere); @@ -238,18 +244,24 @@ export function colonneDuCritere(critere) { 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. +// 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) }); // La phrase des critères appliqués, morceau par morceau : le début, chaque -// critère par son nom moteur.critere., puis l'identifiant croissant, -// séparés, et la fin. Bout à bout, les morceaux traduits composent le texte -// de propositions.ordre.criteres. +// critère de la liste aplatie par son nom moteur.critere., 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 = [ - ...criteresAppliques.map((critere) => designant(critere, `moteur.critere.${critere}`)), + ...aplatir(criteresAppliques).map((critere) => designant(critere, `moteur.critere.${critere}`)), designant('identifiant', 'propositions.ordre.identifiant'), ]; return [ @@ -259,13 +271,22 @@ function phraseDesCriteres(criteresAppliques) { ]; } -// Les phrases de l'ordre (§ 5.7), chacune une liste de morceaux : le critère -// sauté et celui appliqué à sa place quand le plafond a priori manque, puis -// les critères appliqués. -function ordre({ criteresAppliques, critereSaute }) { +// 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é. +function palierAppliqueASaPlace({ criteresAppliques, rangSaute }) { + const palier = criteresAppliques[rangSaute]; + return palier === undefined ? null : palier.criteres.map(({ nom, poids }) => ({ nom, poids })); +} + +// 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. +function ordre(classement) { + const { criteresAppliques, critereSaute } = classement; const phrases = []; if (critereSaute !== null) { - phrases.push([liaison('propositions.ordre.saute', { saute: critereSaute, applique: criteresAppliques[0] })]); + phrases.push([liaison('propositions.ordre.saute', { saute: critereSaute, applique: palierAppliqueASaPlace(classement) })]); } phrases.push(phraseDesCriteres(criteresAppliques)); return phrases; diff --git a/src/interface/propositions/modele.test.js b/src/interface/propositions/modele.test.js index 7155898..2183e08 100644 --- a/src/interface/propositions/modele.test.js +++ b/src/interface/propositions/modele.test.js @@ -440,13 +440,16 @@ describe('tableau : l’ordre écrit (§ 5.7, § 17 point 4)', () => { assert.deepEqual(vue.ordre[0], [ { cle: 'propositions.ordre.saute', - details: { saute: 'ecartAuPlafondAPrioriMax', applique: 'excedentCollisions' }, + details: { saute: 'ecartAuPlafondAPrioriMax', applique: [{ nom: 'excedentCollisions', poids: 1 }] }, critere: null, colonne: null, }, ]); - const saute = phraseDe(vue.ordre[0]); - assert.ok(saute.indexOf('écart au plafond a priori') >= 0 && saute.indexOf('excédent de collisions') > saute.indexOf('écart au plafond a priori'), saute); + assert.equal( + phraseDe(vue.ordre[0]), + 'Critère sauté, faute de plafond a priori par personne : écart au plafond a priori. ' + + 'Appliqué à sa place : excédent de collisions.', + ); assert.equal( phraseDe(vue.ordre[1]), "Ordre du classement, chaque critère au plus faible d'abord : excédent de collisions, " + @@ -461,6 +464,86 @@ describe('tableau : l’ordre écrit (§ 5.7, § 17 point 4)', () => { ]); for (const ligne of vue.lignes) assert.equal(texte(cellule(ligne, 'ecartItineraireMax')), 'inconnu'); }); + + test('a priori inconnu, aucun palier après l’écart : la phrase nomme le critère sauté et ne nomme rien à sa place', () => { + // Les quatre cases décochées, sans objectif : l'écart forme le seul + // palier, et le classement n'applique plus que l'identifiant. + const charge = structuredClone(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT)); + for (const cle of ['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable', 'varierAppartenances']) charge.reglages[cle] = false; + const table = evaluer(charge, { plafondsAPriori: null }); + assert.deepEqual(table.classement.criteresAppliques, []); + const vue = tableau(table); + assert.deepEqual(vue.ordre[0][0].details, { saute: 'ecartAuPlafondAPrioriMax', applique: null }); + assert.equal(phraseDe(vue.ordre[0]), 'Critère sauté, faute de plafond a priori par personne : écart au plafond a priori.'); + assert.equal(phraseDe(vue.ordre[1]), "Ordre du classement, chaque critère au plus faible d'abord : l'identifiant croissant."); + }); + + // La petite démonstration au conflit inévitable, 1 à 6 « finance », la + // première table attendant « finance » pour la soirée, l'ordre des + // priorités donné quand il l'est. + function aObjectifs(priorites) { + 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'] }]; + if (priorites !== undefined) charge.reglages.priorites = priorites; + return charge; + } + const e = (critere, poids = 1) => ({ critere, poids }); + + test('une case décochée retire son critère de la phrase : « varier » décochée, la redondance ne s’y nomme plus (Q4)', () => { + const charge = structuredClone(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT)); + charge.reglages.varierAppartenances = false; + const phrase = phraseDe(tableau(evaluer(charge)).ordre[0]); + assert.equal( + phrase, + "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 rencontres répétées, puis l'identifiant croissant.", + ); + }); + + 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', () => { + const vue = tableau(evaluer(generee(aObjectifs(), { graine: 3, nombre: 2, arret: 200 }))); + assert.equal( + phraseDe(vue.ordre[0]), + "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 tours hors objectif, puis rencontres répétées, puis redondance d'appartenance, " + + "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); + }); + + 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', () => { + const priorites = [ + [e('toursHorsObjectif')], + [e('collisionsCumulees')], + [e('ecartAuPlafondAPrioriMax')], + [e('excedentCollisions'), e('redondance', 2)], + [e('rencontresRepetees')], + [e('animateursNonRencontres')], + ]; + const charge = generee(aObjectifs(priorites), { graine: 3, nombre: 2, arret: 200 }); + 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 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 }], + }); + assert.equal( + phraseDe(inconnu.ordre[0]), + 'Critère sauté, faute de plafond a priori par personne : écart au plafond a priori. ' + + "Appliqué à sa place : ensemble, excédent de collisions (poids 1), redondance d'appartenance (poids 2).", + ); + assert.ok(!phraseDe(inconnu.ordre[1]).includes('écart au plafond a priori')); + }); }); describe('tableau : la diversité en colonne (§ 5.7)', () => { diff --git a/src/moteur/classement.js b/src/moteur/classement.js index c943a7a..59cd8fd 100644 --- a/src/moteur/classement.js +++ b/src/moteur/classement.js @@ -1,42 +1,72 @@ // © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -// Classement des propositions (§ 5.7) : un ordre explicite, celui que -// l'interface écrit, et non le scalaire que descend la recherche. Cinq -// critères, chacun à minimiser, s'appliquent dans l'ordre de CRITERES ; deux -// propositions égales sur tous se départagent par identifiant croissant -// (§ 15.5, point 4), jamais par l'ordre reçu. +// Classement des propositions (§ 5.7, A11) : un ordre explicite, celui que +// l'interface écrit, et non le n-uplet que descend la recherche. Il applique +// la compilation de l'instance (compilationDe, criteres.js) : palier par +// palier, dans l'ordre de la liste réglée, la clé d'un palier partagé est la +// somme, sur ses critères, du poids multiplié par la valeur qui classe ; un +// critère seul à son palier y compare ses valeurs l'une après l'autre, chacune +// multipliée par son poids. Toutes sont des entiers exacts, à minimiser ; +// deux propositions égales sur tous les paliers se départagent par +// identifiant croissant (§ 15.5, point 4), jamais par l'ordre reçu. // -// L'excédent de collisions précède les collisions cumulées : un plan qui -// répartit six collisions sur six paires passe devant un plan qui en -// concentre quatre sur une seule (§ 5.4). Les paires distinctes ne sont pas -// une clé ; elles valent les cumulées moins l'excédent. +// Sans compilation, classer applique l'ordre fixe de référence : l'ordre par +// défaut sous les quatre cases cochées, sans objectif ni case des +// animateurs, que le registre compile en cinq paliers — écart, excédent, +// cumulées, rencontres répétées, redondance (A11). +// +// L'excédent de collisions précède les collisions cumulées dans l'ordre par +// défaut : un plan qui répartit six collisions sur six paires passe devant +// un plan qui en concentre quatre sur une seule (§ 5.4). Les paires +// distinctes ne sont pas une clé ; elles valent les cumulées moins l'excédent. +import { + CRITERES as REGISTRE, + PRIORITES_PAR_DEFAUT, + compilationDe, + estCoche, + valeursQuiClassent, +} from './criteres.js'; import { ecartsAuPlafondAPriori } from './manque.js'; -/** - * Les critères du classement, dans leur ordre d'application : - * - ecartAuPlafondAPrioriMax : le plus grand écart au plafond a priori, - * plafond a priori − rencontres, sur toutes les personnes, sous le nom - * que lui donne le glossaire du § 5.5 ; - * - excedentCollisions et collisionsCumulees, tels que mesurer les rend ; - * - rencontresRepetees : les rencontres répétées que le moteur a choisies ; - * celles qu'imposent les réservations sont les mêmes pour toute - * proposition, et ne départagent rien (§ 5.4) ; - * - redondance : Σ_p r(p), la redondance d'appartenance sommée sur les - * personnes, que mesurer rend sous le nom totalRedondance. - */ -export const CRITERES = Object.freeze([ - 'ecartAuPlafondAPrioriMax', - 'excedentCollisions', - 'collisionsCumulees', - 'rencontresRepetees', - 'redondance', -]); - // Le critère qui lit le plafond a priori par personne : sauté quand il est -// inconnu (§ 17, point 4). +// inconnu (§ 17, point 4), avec son palier, où il est seul (A11). const CRITERE_A_PRIORI = 'ecartAuPlafondAPrioriMax'; +// Les cases de l'ordre fixe de référence : les quatre de ses critères, +// cochées ; celles des objectifs et des animateurs, décochées. Sans objectif +// ni case des animateurs, ces deux critères ne comptent pas — décochés comme +// ici, ou sans objet, la compilation est la même (D10). +const CASES_DE_REFERENCE = Object.freeze({ + separerAppartenances: true, + nouveauxVoisins: true, + nouvelleTable: true, + varierAppartenances: true, + respecterObjectifs: false, + rencontrerAnimateurs: false, +}); + +// La compilation de l'ordre fixe de référence, l'état des critères construit +// comme normaliser le construit (configuration.js). +const REFERENCE = compilationDe({ + priorites: PRIORITES_PAR_DEFAUT, + modeObjectifs: 'places', + etatCriteres: REGISTRE.map((nom) => ({ + nom, + coche: estCoche(nom, CASES_DE_REFERENCE), + sansObjet: false, + valeurs: valeursQuiClassent(nom, CASES_DE_REFERENCE, 'places'), + })), +}); + +/** + * Les noms des critères que l'ordre fixe de référence applique, palier après + * palier : la liste aplatie des critères appliqués de classer sans + * compilation, plafond a priori connu. Dérivée du registre, elle n'en recopie + * aucun nom. + */ +export const CRITERES = Object.freeze(REFERENCE.paliers.flatMap(({ criteres }) => criteres.map(({ nom }) => nom))); + // Le plus grand élément d'une liste ; null pour une liste vide. function plusGrand(valeurs) { let plus = null; @@ -44,18 +74,48 @@ function plusGrand(valeurs) { return plus; } -// Valeur de chaque critère pour une proposition lue : { mesures, ecarts }, +// Chaque valeur qui classe, lue pour une proposition : { mesures, ecarts }, // ecarts étant ses écarts au plafond a priori. Chaque grandeur se lit dans -// la mesure ou dans manque.js, aucune ne se refait ici. La Map ne sert qu'à -// retrouver un critère par son nom ; CRITERES en fixe l'ordre. +// la mesure ou dans manque.js, aucune ne se refait ici ; les tours hors +// objectif sont le horsObjectif de la mesure. La Map ne sert qu'à retrouver +// une valeur par son nom ; la compilation en fixe l'ordre. const VALEUR = new Map([ ['ecartAuPlafondAPrioriMax', ({ ecarts }) => plusGrand(ecarts)], ['excedentCollisions', ({ mesures }) => mesures.excedentCollisions], ['collisionsCumulees', ({ mesures }) => mesures.collisionsCumulees], - ['rencontresRepetees', ({ mesures }) => mesures.rencontresRepetees.choisies], + ['toursHorsObjectif', ({ mesures }) => mesures.objectifs.horsObjectif], + ['jamaisServies', ({ mesures }) => mesures.objectifs.jamaisServies], + ['horsObjectifMax', ({ mesures }) => mesures.objectifs.horsObjectifMax], + ['rencontresRepeteesChoisies', ({ mesures }) => mesures.rencontresRepetees.choisies], + ['retoursChoisis', ({ mesures }) => mesures.totalRetoursChoisis], + ['animateursNonRencontres', ({ mesures }) => mesures.animateurs.nonRencontres], ['redondance', ({ mesures }) => mesures.totalRedondance], ]); +// La lecture d'une valeur qui classe ; RangeError pour un nom qu'aucune +// lecture ne porte. +function lecture(valeur) { + const lire = VALEUR.get(valeur); + if (lire === undefined) throw new RangeError(`valeur de classement inconnue : ${valeur}`); + return lire; +} + +// Les composantes de la clé de classement d'un palier, chacune une liste de +// termes [poids, lecture] à sommer : une par valeur pour un critère seul à +// son palier, une seule, la somme pondérée de chaque critère sur sa valeur, +// pour un palier partagé. Un critère qui classe deux valeurs est seul à son +// palier (criteres.js) ; RangeError s'il ne l'est pas. +function composantesDuPalier({ criteres }) { + if (criteres.length === 1) { + const [{ poids, valeurs }] = criteres; + return valeurs.map((valeur) => [[poids, lecture(valeur)]]); + } + return [criteres.map(({ nom, poids, valeurs }) => { + if (valeurs.length !== 1) throw new RangeError(`critère ${nom} : ${valeurs.length} valeurs dans un palier partagé`); + return [poids, lecture(valeurs[0])]; + })]; +} + // Lève TypeError pour un identifiant qui n'est pas un entier, et RangeError // pour un identifiant répété : deux entrées de même identifiant ne se // départagent pas. L'ensemble ne sert qu'à retrouver un identifiant déjà vu. @@ -90,7 +150,7 @@ function exigerMemeTaille(entrees) { // Ordre lexicographique de deux clés de même longueur. Sur une population // vide, le plus grand écart au plafond a priori vaut null pour chaque entrée, -// toutes de même taille : deux null sont égaux. +// que son poids ramène à 0 : toutes y sont égales. function comparerCles(a, b) { for (let i = 0; i < a.length; i += 1) { if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; @@ -99,16 +159,26 @@ function comparerCles(a, b) { } /** - * Ordre des propositions (§ 5.7), la meilleure d'abord : critère par critère - * dans l'ordre de CRITERES, chacun croissant, puis identifiant croissant. + * Ordre des propositions (§ 5.7, A11), la meilleure d'abord : palier par + * palier dans l'ordre de la compilation, chacun croissant, puis identifiant + * croissant (en-tête du module). * * Chaque entrée porte id, l'identifiant entier de la proposition ; mesures, * telles que mesurer les rend ; plafondsAPriori, la liste de plafond.js, ou * null quand le plafond a priori est inconnu (§ 17, point 4). Une seule - * entrée à plafond a priori inconnu fait sauter le premier critère pour - * toutes : critereSaute le nomme, et criteresAppliques s'ouvre sur le critère - * appliqué à sa place (§ 5.7). Sinon critereSaute vaut null et - * criteresAppliques reprend CRITERES. + * entrée à plafond a priori inconnu fait sauter, pour toutes, le palier de + * l'écart au plafond a priori, où il est seul : critereSaute le nomme, + * criteresAppliques omet ce palier, et rangSaute est son rang, à partir de + * 0, dans la compilation — criteresAppliques[rangSaute] est le palier qui + * prend sa place, absent quand aucun ne le suit. Sinon critereSaute et + * rangSaute valent null. + * + * compilation : ce que rend compilationDe (criteres.js) pour l'instance + * mesurée ; par défaut, l'ordre fixe de référence. criteresAppliques porte, + * par palier appliqué dans son ordre, ses critères {nom, valeurs, poids}, + * des copies : valeurs, les grandeurs qu'il classe, comparées dans l'ordre. + * Sous l'ordre par défaut et les quatre cases cochées, la liste aplatie des + * noms égale CRITERES. * * Toutes les entrées sont mesurées sur une même population : leurs chiffres * ne se comparent qu'à cette condition. classer n'en contrôle que la taille, @@ -123,14 +193,17 @@ function comparerCles(a, b) { * Lève TypeError pour un identifiant qui n'est pas un entier, ou un * plafondsAPriori qui n'est ni une liste ni null ; RangeError pour un * identifiant répété, pour des mesures de populations de tailles - * différentes, ou pour un plafond a priori qui n'a pas une case par - * personne mesurée. Ne modifie pas ce qu'il reçoit. + * différentes, pour un plafond a priori qui n'a pas une case par personne + * mesurée, ou pour une valeur que classer ne sait pas lire. Ne modifie pas + * ce qu'il reçoit. * * @param {Array<{id: number, mesures: import('./indicateurs.js').Mesures, * plafondsAPriori: ArrayLike|null}>} entrees - * @returns {{ordre: number[], criteresAppliques: string[], critereSaute: string|null}} + * @param {ReturnType} [compilation] + * @returns {{ordre: number[], criteresAppliques: Array<{criteres: Array<{nom: string, valeurs: string[], poids: number}>}>, + * critereSaute: string|null, rangSaute: number|null}} */ -export function classer(entrees) { +export function classer(entrees, compilation = REFERENCE) { exigerIdentifiants(entrees); exigerMemeTaille(entrees); const lues = entrees.map(({ id, mesures, plafondsAPriori }) => ({ @@ -139,15 +212,26 @@ export function classer(entrees) { ecarts: ecartsAuPlafondAPriori(mesures, plafondsAPriori), })); const saute = lues.some(({ ecarts }) => ecarts === null); - const criteresAppliques = CRITERES.filter((critere) => !saute || critere !== CRITERE_A_PRIORI); + const rangSaute = saute + ? compilation.paliers.findIndex(({ criteres }) => criteres.some(({ nom }) => nom === CRITERE_A_PRIORI)) + : -1; + const appliques = compilation.paliers.filter((_, rang) => rang !== rangSaute); + const composantes = appliques.flatMap(composantesDuPalier); const classees = lues.map((lue) => ({ id: lue.id, - cle: criteresAppliques.map((critere) => VALEUR.get(critere)(lue)), + cle: composantes.map((termes) => { + let somme = 0; + for (const [poids, lire] of termes) somme += poids * lire(lue); + return somme; + }), })); classees.sort((a, b) => comparerCles(a.cle, b.cle) || a.id - b.id); return { ordre: classees.map(({ id }) => id), - criteresAppliques, + criteresAppliques: appliques.map(({ criteres }) => ({ + criteres: criteres.map(({ nom, valeurs, poids }) => ({ nom, valeurs: [...valeurs], poids })), + })), critereSaute: saute ? CRITERE_A_PRIORI : null, + rangSaute: rangSaute < 0 ? null : rangSaute, }; } diff --git a/src/moteur/classement.test.js b/src/moteur/classement.test.js index 27228ac..9a56406 100644 --- a/src/moteur/classement.test.js +++ b/src/moteur/classement.test.js @@ -1,23 +1,54 @@ // © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -// Épreuves du classement des propositions (§ 5.7). Une proposition +// Épreuves du classement des propositions (§ 5.7, A11, A21). Une proposition // synthétique ne porte que les champs que classer lit, pour trois personnes // au plafond a priori de 8. Chaque ordre attendu est écrit en clair. Hors des // épreuves où seul l'identifiant départage, chaque épreuve qui attend un // ordre non vide en attend au moins un qui diffère de l'ordre des // identifiants : un classement qui ne lirait aucun critère, et rangerait par -// identifiant, y échoue. +// identifiant, y échoue. Sans compilation, classer applique l'ordre fixe de +// référence ; une compilation (compilationDe, criteres.js) range les paliers +// et pèse leurs critères, et chaque mode des objectifs y compare ses valeurs. import assert from 'node:assert/strict'; import { describe, test } from '../../test/lanceur.js'; import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js'; import { CRITERES, classer } from './classement.js'; import { indexerPlan, normaliser } from './configuration.js'; +import { CRITERES as REGISTRE, compilationDe, estCoche, valeursQuiClassent } from './criteres.js'; import { mesurer } from './indicateurs.js'; +// Les noms des critères appliqués, palier après palier : la liste aplatie, +// égale à CRITERES sous l'ordre fixe de référence. +const aplatir = (criteresAppliques) => criteresAppliques.flatMap(({ criteres }) => criteres.map(({ nom }) => nom)); + // Les critères appliqués quand le premier est sauté. const SANS_A_PRIORI = ['excedentCollisions', 'collisionsCumulees', 'rencontresRepetees', 'redondance']; +// Ce que classer rend, ses critères appliqués lus en noms. +const lu = ({ ordre, criteresAppliques, critereSaute }) => ({ ordre, criteres: aplatir(criteresAppliques), critereSaute }); + +const QUATRE_CASES = { separerAppartenances: true, nouveauxVoisins: true, nouvelleTable: true, varierAppartenances: true }; + +// Une entrée de palier. +const e = (critere, poids = 1) => ({ critere, poids }); + +// La compilation d'un ordre réglé sous ces contraintes et ce mode, comme +// normaliser la donnerait : les tours hors objectif et les animateurs non +// rencontrés ont un objet quand avecObjet les nomme, aucun sinon. +function compilationSous(priorites, { contraintes = QUATRE_CASES, mode = 'places', avecObjet = [] } = {}) { + const etatCriteres = REGISTRE.map((nom) => ({ + nom, + coche: estCoche(nom, contraintes), + sansObjet: (nom === 'toursHorsObjectif' || nom === 'animateursNonRencontres') && !avecObjet.includes(nom), + valeurs: valeursQuiClassent(nom, contraintes, mode), + })); + return compilationDe({ priorites, etatCriteres, modeObjectifs: mode }); +} + +// L'ordre par défaut, chaque critère seul à son palier. +const PAR_DEFAUT = REGISTRE.map((nom) => [e(nom)]); + // Fige une valeur et, à toute profondeur, les objets et les tableaux qu'elle // contient : une écriture du module éprouvé y lève TypeError. function figer(valeur) { @@ -40,7 +71,10 @@ function proposition( collisions = [0, 0, 0], repetees = 0, imposees = 0, + retours = 0, redondance = 0, + objectifs = null, + nonRencontres = null, } = {}, ) { const [collisionsCumulees, pairesDistinctes, excedentCollisions] = collisions; @@ -52,12 +86,19 @@ function proposition( pairesDistinctes, excedentCollisions, rencontresRepetees: { choisies: repetees, imposees }, + totalRetoursChoisis: retours, totalRedondance: redondance, + objectifs, + animateurs: nonRencontres === null ? null : { nonRencontres }, }, plafondsAPriori: [8, 8, 8], }; } +// Les mesures des objectifs que classer lit : les tours hors objectif, les +// personnes jamais servies, le plus de tours hors objectif d'une personne. +const objectifs = (horsObjectif, jamaisServies, horsObjectifMax) => ({ horsObjectif, jamaisServies, horsObjectifMax }); + // La même proposition, son plafond a priori inconnu. const inconnu = (entree) => ({ ...entree, plafondsAPriori: null }); @@ -65,7 +106,7 @@ const inconnu = (entree) => ({ ...entree, plafondsAPriori: null }); const permutations = ([a, b, c]) => [[a, b, c], [a, c, b], [b, a, c], [b, c, a], [c, a, b], [c, b, a]]; describe('classer : les critères et leur ordre (§ 5.7)', () => { - test('CRITERES nomme les cinq critères dans leur ordre, et ne se modifie pas', () => { + test("CRITERES nomme les cinq critères de l'ordre fixe de référence dans leur ordre, et ne se modifie pas", () => { assert.deepEqual(CRITERES, [ 'ecartAuPlafondAPrioriMax', 'excedentCollisions', @@ -109,9 +150,9 @@ describe('classer : les critères et leur ordre (§ 5.7)', () => { proposition(5, { redondance: 1 }), // redondance 1 proposition(6), ]; - assert.deepEqual(classer(figer(entrees)), { + assert.deepEqual(lu(classer(figer(entrees))), { ordre: [6, 5, 4, 3, 2, 1], - criteresAppliques: [...CRITERES], + criteres: [...CRITERES], critereSaute: null, }); }); @@ -153,17 +194,17 @@ describe('classer : le critère sauté (§ 5.7, § 12.6)', () => { // 2 : écart au plafond a priori 1, excédent 0. const premiere = proposition(1, { collisions: [4, 2, 2], repetees: 2 }); const seconde = proposition(2, { rencontres: [7, 8, 8] }); - assert.deepEqual(classer([premiere, seconde]), { + assert.deepEqual(lu(classer([premiere, seconde])), { ordre: [1, 2], - criteresAppliques: [...CRITERES], + criteres: [...CRITERES], critereSaute: null, }); // Un seul a priori inconnu suffit : l'excédent ouvre alors le classement // de toutes, y compris de celle dont l'a priori est connu. - const attendu = { ordre: [2, 1], criteresAppliques: SANS_A_PRIORI, critereSaute: 'ecartAuPlafondAPrioriMax' }; - assert.deepEqual(classer([premiere, inconnu(seconde)]), attendu); - assert.deepEqual(classer([inconnu(premiere), seconde]), attendu); - assert.deepEqual(classer([inconnu(premiere), inconnu(seconde)]), attendu); + const attendu = { ordre: [2, 1], criteres: SANS_A_PRIORI, critereSaute: 'ecartAuPlafondAPrioriMax' }; + assert.deepEqual(lu(classer([premiere, inconnu(seconde)])), attendu); + assert.deepEqual(lu(classer([inconnu(premiere), seconde])), attendu); + assert.deepEqual(lu(classer([inconnu(premiere), inconnu(seconde)])), attendu); }); }); @@ -212,9 +253,9 @@ describe('classer : petite démonstration, conflit inévitable (§ 14.10, § 15. ]; assert.deepEqual(classer(entrees).ordre, [2, 1]); // Le plafond a priori inconnu, l'excédent décide seul : 0 contre 3. - assert.deepEqual(classer(entrees.map(inconnu)), { + assert.deepEqual(lu(classer(entrees.map(inconnu))), { ordre: [2, 1], - criteresAppliques: SANS_A_PRIORI, + criteres: SANS_A_PRIORI, critereSaute: 'ecartAuPlafondAPrioriMax', }); }); @@ -222,16 +263,16 @@ describe('classer : petite démonstration, conflit inévitable (§ 14.10, § 15. describe('classer : les entrées reçues', () => { test("une liste vide rend un ordre vide, tous les critères appliqués", () => { - assert.deepEqual(classer([]), { ordre: [], criteresAppliques: [...CRITERES], critereSaute: null }); + assert.deepEqual(lu(classer([])), { ordre: [], criteres: [...CRITERES], critereSaute: null }); }); test("population vide, plafond a priori connu : le premier critère s'applique, sans rien départager", () => { // Une population vide n'a pas de plus grand écart au plafond a priori ; // ce n'est pas un plafond a priori inconnu, et rien n'est sauté. const vide = (id) => ({ ...proposition(id, { rencontres: [] }), plafondsAPriori: [] }); - assert.deepEqual(classer([vide(2), vide(1)]), { + assert.deepEqual(lu(classer([vide(2), vide(1)])), { ordre: [1, 2], - criteresAppliques: [...CRITERES], + criteres: [...CRITERES], critereSaute: null, }); }); @@ -256,3 +297,242 @@ describe('classer : les entrées reçues', () => { assert.throws(() => classer([proposition(1), quatre]), RangeError); }); }); + +describe('classer : la forme des critères appliqués (contrat du classement)', () => { + test("sans compilation : l'ordre fixe de référence, un palier par critère, ses valeurs et son poids", () => { + assert.deepEqual(classer([proposition(1)]).criteresAppliques, [ + { criteres: [{ nom: 'ecartAuPlafondAPrioriMax', valeurs: ['ecartAuPlafondAPrioriMax'], poids: 1 }] }, + { criteres: [{ nom: 'excedentCollisions', valeurs: ['excedentCollisions'], poids: 1 }] }, + { criteres: [{ nom: 'collisionsCumulees', valeurs: ['collisionsCumulees'], poids: 1 }] }, + { criteres: [{ nom: 'rencontresRepetees', valeurs: ['rencontresRepeteesChoisies'], poids: 1 }] }, + { criteres: [{ nom: 'redondance', valeurs: ['redondance'], poids: 1 }] }, + ]); + }); + + test("l'ordre par défaut compilé sous les quatre cases, sans objectif ni animateurs, rend le même classement que sans compilation", () => { + const entrees = [ + proposition(1, { rencontres: [7, 8, 8] }), + proposition(2, { collisions: [2, 1, 1], repetees: 1 }), + proposition(3, { redondance: 1 }), + ]; + assert.deepEqual(classer(entrees, compilationSous(PAR_DEFAUT)), classer(entrees)); + for (const mode of ['chacun', 'minimum']) { + assert.deepEqual(classer(entrees, compilationSous(PAR_DEFAUT, { mode })), classer(entrees), mode); + } + }); + + test('les critères appliqués sont des copies : les modifier ne touche ni la compilation ni le classement suivant', () => { + const compilation = compilationSous(PAR_DEFAUT); + const { criteresAppliques } = classer([proposition(1)], compilation); + criteresAppliques[0].criteres[0].valeurs.push('autre'); + assert.deepEqual(compilation.paliers[0].criteres[0].valeurs, ['ecartAuPlafondAPrioriMax']); + assert.deepEqual(aplatir(classer([proposition(1)], compilation).criteresAppliques), [...CRITERES]); + }); +}); + +describe('classer : un palier pondéré classe par sa somme (A11, A21)', () => { + test('deux propositions égales sur la somme du palier : le suivant les départage ; la troisième, moins bonne sur ce palier, reste derrière', () => { + // Palier 2 : { excédent × 2, redondance × 1 }, puis les collisions + // cumulées. 1 : 2 × 1 + 3 = 5 ; 2 : 2 × 2 + 1 = 5 ; 3 : 2 × 0 + 6 = 6. + // Lu critère par critère, l'excédent nul de 3 la mettrait en tête. + const liste = [ + [e('ecartAuPlafondAPrioriMax')], + [e('excedentCollisions', 2), e('redondance')], + [e('collisionsCumulees')], + [e('toursHorsObjectif')], + [e('rencontresRepetees')], + [e('animateursNonRencontres')], + ]; + const entrees = [ + proposition(1, { collisions: [5, 4, 1], redondance: 3 }), + proposition(2, { collisions: [4, 2, 2], redondance: 1 }), + proposition(3, { collisions: [0, 0, 0], redondance: 6 }), + ]; + const classement = classer(entrees, compilationSous(liste)); + assert.deepEqual(classement.ordre, [2, 1, 3]); + assert.deepEqual(classement.criteresAppliques[1], { + criteres: [ + { nom: 'excedentCollisions', valeurs: ['excedentCollisions'], poids: 2 }, + { nom: 'redondance', valeurs: ['redondance'], poids: 1 }, + ], + }); + }); + + test('le poids renverse l’ordre de la somme brute : le palier compare Σ poids × valeur, non Σ valeur', () => { + // Palier 2 : { excédent × 2, redondance × 1 }, les collisions cumulées + // égales. 2 : 2 × 1 + 4 = 6, brut 1 + 4 = 5 ; 1 : 2 × 3 + 1 = 7, brut + // 3 + 1 = 4. Pondérée, la somme met 2 en tête ; brute, elle y mettrait 1. + const liste = [ + [e('ecartAuPlafondAPrioriMax')], + [e('excedentCollisions', 2), e('redondance')], + [e('collisionsCumulees')], + [e('toursHorsObjectif')], + [e('rencontresRepetees')], + [e('animateursNonRencontres')], + ]; + const entrees = [ + proposition(1, { collisions: [4, 1, 3], redondance: 1 }), + proposition(2, { collisions: [4, 3, 1], redondance: 4 }), + ]; + assert.deepEqual(classer(entrees, compilationSous(liste)).ordre, [2, 1]); + }); + + test("le poids d'un critère seul à son palier multiplie sa valeur sans changer l'ordre qu'il donne", () => { + const liste = [[e('ecartAuPlafondAPrioriMax')], [e('excedentCollisions', 7)], ...PAR_DEFAUT.slice(2)]; + const entrees = [proposition(1, { collisions: [2, 1, 1] }), proposition(2)]; + assert.deepEqual(classer(entrees, compilationSous(liste)).ordre, [2, 1]); + }); +}); + +describe('classer : les cases et les critères qui ne comptent pas (§ 5.3, D10, Q4)', () => { + test('un critère décoché ne classe pas : séparer les appartenances décochée, l’excédent ne départage plus', () => { + const entrees = [proposition(1, { collisions: [2, 1, 1] }), proposition(2, { repetees: 1 })]; + assert.deepEqual(classer(entrees).ordre, [2, 1]); + const contraintes = { ...QUATRE_CASES, separerAppartenances: false }; + const classement = classer(entrees, compilationSous(PAR_DEFAUT, { contraintes })); + assert.deepEqual(lu(classement), { + ordre: [1, 2], + criteres: ['ecartAuPlafondAPrioriMax', 'rencontresRepetees', 'redondance'], + critereSaute: null, + }); + }); + + test('« nouvelle table » seule : les retours choisis classent à la place des rencontres répétées', () => { + // 1 : une rencontre répétée, aucun retour ; 2 : deux retours. + const entrees = [proposition(1, { repetees: 1 }), proposition(2, { retours: 2 })]; + const contraintes = { ...QUATRE_CASES, nouveauxVoisins: false }; + const classement = classer(entrees, compilationSous(PAR_DEFAUT, { contraintes })); + assert.deepEqual(classement.ordre, [1, 2]); + assert.deepEqual(classement.criteresAppliques[3].criteres, [{ nom: 'rencontresRepetees', valeurs: ['retoursChoisis'], poids: 1 }]); + assert.deepEqual(classer(entrees).ordre, [2, 1]); + }); + + test('les animateurs cochés et avec un objet classent sur les animateurs non rencontrés, avant la redondance', () => { + const entrees = [proposition(1, { nonRencontres: 3 }), proposition(2, { nonRencontres: 1, redondance: 4 })]; + const contraintes = { ...QUATRE_CASES, rencontrerAnimateurs: true }; + const classement = classer(entrees, compilationSous(PAR_DEFAUT, { contraintes, avecObjet: ['animateursNonRencontres'] })); + assert.deepEqual(classement.ordre, [2, 1]); + assert.deepEqual(aplatir(classement.criteresAppliques).slice(-2), ['animateursNonRencontres', 'redondance']); + }); + + test("le critère sauté sort avec son palier, où qu'il soit rangé ; critereSaute le nomme, rangSaute dit son rang", () => { + const liste = [[e('excedentCollisions')], [e('collisionsCumulees')], [e('ecartAuPlafondAPrioriMax')], ...PAR_DEFAUT.slice(3)]; + const entrees = [inconnu(proposition(1, { repetees: 2 })), inconnu(proposition(2, { rencontres: [1, 1, 1], repetees: 1 }))]; + const classement = classer(entrees, compilationSous(liste)); + assert.deepEqual(lu(classement), { + ordre: [2, 1], + criteres: ['excedentCollisions', 'collisionsCumulees', 'rencontresRepetees', 'redondance'], + critereSaute: 'ecartAuPlafondAPrioriMax', + }); + // Le palier des rencontres répétées prend le rang 2, celui de l'écart. + assert.equal(classement.rangSaute, 2); + assert.equal(classement.criteresAppliques[2].criteres[0].nom, 'rencontresRepetees'); + assert.equal(classer(entrees).rangSaute, 0); + assert.equal(classer([proposition(1)]).rangSaute, null); + }); + + test('les quatre cases décochées, sans objectif : le palier sauté était le seul, aucun ne prend sa place', () => { + const contraintes = { separerAppartenances: false, nouveauxVoisins: false, nouvelleTable: false, varierAppartenances: false }; + const entrees = [inconnu(proposition(2)), inconnu(proposition(1, { repetees: 3 }))]; + const classement = classer(entrees, compilationSous(PAR_DEFAUT, { contraintes })); + assert.deepEqual(classement.criteresAppliques, []); + assert.equal(classement.critereSaute, 'ecartAuPlafondAPrioriMax'); + assert.equal(classement.rangSaute, 0); + assert.deepEqual(classement.ordre, [1, 2]); + }); +}); + +describe('classer : un critère à deux valeurs, seul à son palier, compare la première puis la seconde (D20)', () => { + // 1 : jamais servies 1, tours hors objectif 2, le plus d'une personne 2 ; + // 2 : 0, 5, 3 ; 3 : 0, 3, 1 ; 4 : 0, 4, 1. + const entrees = () => [ + proposition(1, { objectifs: objectifs(2, 1, 2) }), + proposition(2, { objectifs: objectifs(5, 0, 3) }), + proposition(3, { objectifs: objectifs(3, 0, 1) }), + proposition(4, { objectifs: objectifs(4, 0, 1) }), + ]; + const sous = (mode) => compilationSous(PAR_DEFAUT, { mode, avecObjet: ['toursHorsObjectif'] }); + + test('places : les tours hors objectif seuls', () => { + assert.deepEqual(classer(entrees(), sous('places')).ordre, [1, 3, 4, 2]); + }); + + test('chacun : les personnes jamais servies, puis les tours hors objectif', () => { + const classement = classer(entrees(), sous('chacun')); + assert.deepEqual(classement.ordre, [3, 4, 2, 1]); + assert.deepEqual(classement.criteresAppliques[3].criteres, [ + { nom: 'toursHorsObjectif', valeurs: ['jamaisServies', 'toursHorsObjectif'], poids: 1 }, + ]); + }); + + test("minimum : le plus de tours hors objectif d'une personne, puis les tours hors objectif", () => { + assert.deepEqual(classer(entrees(), sous('minimum')).ordre, [3, 4, 1, 2]); + }); +}); + +// Dix personnes « finance », une table « finance » de huit places et une +// table sans objectif de deux, quatre tours : chaque plan sert 32 places. +// Seuls les objectifs comptent (les quatre cases décochées) ; le plafond a +// priori, inconnu, fait sauter l'écart. +function soireeFinance(mode) { + return normaliser({ + participants: Array.from({ length: 10 }, (_, rang) => ({ id: rang + 1, appartenance: null, etiquettes: ['finance'] })), + tables: [{ id: 1, capacite: 8 }, { id: 2, capacite: 2 }], + tours: 4, + reservations: [], + contraintes: { separerAppartenances: false, nouveauxVoisins: false, nouvelleTable: false, varierAppartenances: false }, + objectifs: [{ table: 1, tour: null, regle: 'ou', etiquettes: ['finance'] }], + ...(mode === 'places' ? {} : { modeObjectifs: mode }), + }); +} + +// Le plan dont la table de deux reçoit, au tour r, la paire paires[r]. +function planFinance(paires) { + return { + tables: [1, 2], + tours: paires.map((paire) => [Array.from({ length: 10 }, (_, rang) => rang + 1).filter((id) => !paire.includes(id)), paire]), + reserves: [[], [], [], []], + }; +} + +// L'un laisse 10 sans tour servi ; l'autre sert chacun une fois au moins, +// 9 et 10 une fois seulement ; le dernier sert chacun trois tours au moins. +const PLANS_FINANCE = [ + planFinance([[9, 10], [8, 10], [7, 10], [6, 10]]), + planFinance([[9, 10], [9, 10], [9, 10], [7, 8]]), + planFinance([[1, 2], [3, 4], [5, 6], [7, 8]]), +]; + +describe('classer : chaque mode des objectifs compare ce qu’il dit (A21)', () => { + // Les trois plans mesurés et classés sous ce mode. + function classerFinance(mode) { + const instance = soireeFinance(mode); + const entrees = PLANS_FINANCE.map((plan, rang) => ({ + id: rang + 1, + mesures: mesurer(instance, indexerPlan(instance, plan)), + plafondsAPriori: null, + })); + return { entrees, classement: classer(entrees, compilationDe(instance)) }; + } + + test('les trois plans servent 32 places : les mêmes tours hors objectif ; jamais servies 1, 0, 0 ; le plus d’une personne 4, 3, 1', () => { + const { entrees } = classerFinance('places'); + assert.deepEqual( + entrees.map(({ mesures }) => [mesures.objectifs.horsObjectif, mesures.objectifs.jamaisServies, mesures.objectifs.horsObjectifMax]), + [[8, 1, 4], [8, 0, 3], [8, 0, 1]], + ); + }); + + test('le plus de places servies : les trois égaux sur le critère, rangés par identifiant', () => { + const { classement } = classerFinance('places'); + assert.deepEqual(lu(classement), { ordre: [1, 2, 3], criteres: ['toursHorsObjectif'], critereSaute: 'ecartAuPlafondAPrioriMax' }); + }); + + test('chacun au moins une fois : le premier passe derrière les deux autres', () => { + assert.deepEqual(classerFinance('chacun').classement.ordre, [2, 3, 1]); + }); + + test("l'équité d'abord : le dernier passe devant les deux autres", () => { + assert.deepEqual(classerFinance('minimum').classement.ordre, [3, 2, 1]); + }); +}); diff --git a/src/moteur/criteres.js b/src/moteur/criteres.js index f9e6d17..11c4392 100644 --- a/src/moteur/criteres.js +++ b/src/moteur/criteres.js @@ -374,3 +374,34 @@ export function compiler(liste, compte, mode = 'places') { taille, }; } + +/** + * La compilation d'une instance (§ 5.7, D10) : sa liste de priorités, déjà + * complète, compilée sous son mode, chaque critère comptant quand son état le + * dit coché et avec un objet. etatCriteres porte un état par critère, dans + * l'ordre de CRITERES ({nom, coche, sansObjet, valeurs}, normaliser). + * + * Rend ce que compiler rend, où chaque critère d'un palier porte en plus + * valeurs, une copie des grandeurs qu'il classe, dans l'ordre où le + * classement les compare : la recherche et le classement lisent ainsi une + * seule compilation. Lève ce que compiler lève, et RangeError quand + * etatCriteres ne suit pas l'ordre de CRITERES. Ne modifie rien de ce + * qu'elle reçoit. + * + * @param {{priorites: Array>, + * etatCriteres: Array<{nom: string, coche: boolean, sansObjet: boolean, valeurs: string[]}>, + * modeObjectifs: 'places'|'chacun'|'minimum'}} instance + * @returns {ReturnType} + */ +export function compilationDe({ priorites, etatCriteres, modeObjectifs }) { + const etatDe = (nom) => { + const etat = etatCriteres[rangDe(nom)]; + if (etat?.nom !== nom) throw new RangeError(`état des critères : ${nom} attendu au rang ${rangDe(nom)}`); + return etat; + }; + const compilation = compiler(priorites, (nom) => etatDe(nom).coche && !etatDe(nom).sansObjet, modeObjectifs); + for (const palier of compilation.paliers) { + palier.criteres = palier.criteres.map(({ nom, poids }) => ({ nom, poids, valeurs: [...etatDe(nom).valeurs] })); + } + return compilation; +} diff --git a/src/moteur/criteres.test.js b/src/moteur/criteres.test.js index 7e42456..dec7c43 100644 --- a/src/moteur/criteres.test.js +++ b/src/moteur/criteres.test.js @@ -3,14 +3,16 @@ // Épreuves du registre des critères (§ 5.3, § 5.7, D8 à D11, D15, D20, D23) : // les constantes du registre, la validation d'une liste de priorités faute -// par faute sous chaque mode, son complément, les valeurs qui classent, et -// la compilation. La compilation de l'ordre par défaut se compare, sous les +// par faute sous chaque mode, son complément, les valeurs qui classent, la +// compilation, celle d'une instance, et la garde de la contrainte 35 : hors +// du registre, aucune source ne recopie une liste des noms de critères ni un +// ordre de comparaison. La compilation de l'ordre par défaut se compare, sous les // seize combinaisons des quatre cases, au n-uplet que la recherche descend // sans le registre : six composantes, celles d'un critère décoché nulles, // comparées dans l'ordre du score [0..5] puis, l'écart annulé, dans celui // des contraintes [2, 3, 4, 0, 1, 5]. import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; import { describe, test } from '../../test/lanceur.js'; import { CASES, @@ -21,6 +23,7 @@ import { POIDS_MAX, PRIORITES_PAR_DEFAUT, VALEURS_DES_MODES, + compilationDe, compiler, completerPriorites, composantesDe, @@ -52,8 +55,9 @@ const remplacer = (liste, i, ...paliers) => [...liste.slice(0, i), ...paliers, . // Le compte qui fait compter les noms donnés, et eux seuls. const comptant = (...noms) => (nom) => noms.includes(nom); -// Le compte des cases d'aujourd'hui : le critère coché, et ni objectif ni -// case des animateurs (aucun des deux nouveaux critères n'a d'objet). +// Le compte des quatre cases : le critère coché, sans objectif ni case des +// animateurs (ni les tours hors objectif ni les animateurs non rencontrés +// n'ont d'objet). const compteDesCases = (contraintes) => (nom) => estCoche(nom, contraintes) && nom !== 'toursHorsObjectif' && nom !== 'animateursNonRencontres'; @@ -742,3 +746,117 @@ describe('criteres : la source du registre', () => { } }); }); + +// L'état des critères que normaliser rendrait sous ces contraintes et ce +// mode : les tours hors objectif et les animateurs non rencontrés sont sans +// objet, sauf ceux que avecObjet nomme. +const etatSous = (contraintes, mode, ...avecObjet) => CRITERES.map((nom) => ({ + nom, + coche: estCoche(nom, contraintes), + sansObjet: (nom === 'toursHorsObjectif' || nom === 'animateursNonRencontres') && !avecObjet.includes(nom), + valeurs: valeursQuiClassent(nom, contraintes, mode), +})); + +describe('criteres : compilationDe, la compilation d’une instance (D10, contrat du classement)', () => { + test('un critère compte quand il est coché et a un objet ; chacun porte ses valeurs qui classent', () => { + const compilation = compilationDe({ + priorites: PRIORITES_PAR_DEFAUT, + etatCriteres: etatSous(TOUTES_COCHEES, 'places'), + modeObjectifs: 'places', + }); + assert.deepEqual(compilation.paliers.map(({ criteres }) => criteres), [ + [{ nom: 'ecartAuPlafondAPrioriMax', poids: 1, valeurs: ['ecartAuPlafondAPrioriMax'] }], + [{ nom: 'excedentCollisions', poids: 1, valeurs: ['excedentCollisions'] }], + [{ nom: 'collisionsCumulees', poids: 1, valeurs: ['collisionsCumulees'] }], + [{ nom: 'rencontresRepetees', poids: 1, valeurs: ['rencontresRepeteesChoisies'] }], + [{ nom: 'redondance', poids: 1, valeurs: ['redondance'] }], + ]); + const { paliers, ...reste } = compiler(PRIORITES_PAR_DEFAUT, compteDesCases(TOUTES_COCHEES), 'places'); + assert.deepEqual({ ...compilation, paliers: undefined }, { ...reste, paliers: undefined }); + }); + + test('sous chacun, les objectifs ayant un objet : un palier de deux valeurs, « nouvelle table » seule fait classer les retours', () => { + const contraintes = { ...contraintesDu(0b0100), respecterObjectifs: true }; + const compilation = compilationDe({ + priorites: PRIORITES_PAR_DEFAUT, + etatCriteres: etatSous(contraintes, 'chacun', 'toursHorsObjectif'), + modeObjectifs: 'chacun', + }); + assert.deepEqual(compilation.paliers.map(({ criteres }) => criteres.map(({ nom, valeurs }) => [nom, valeurs])), [ + [['ecartAuPlafondAPrioriMax', ['ecartAuPlafondAPrioriMax']]], + [['toursHorsObjectif', ['jamaisServies', 'toursHorsObjectif']]], + [['rencontresRepetees', ['retoursChoisis']]], + ]); + assert.equal(compilation.taille, 5); + }); + + test('un critère décoché ne compte pas, même avec un objet ; un critère sans objet non plus, même coché', () => { + const decoche = compilationDe({ + priorites: PRIORITES_PAR_DEFAUT, + etatCriteres: etatSous({ ...TOUTES_COCHEES, respecterObjectifs: false }, 'places', 'toursHorsObjectif', 'animateursNonRencontres'), + modeObjectifs: 'places', + }); + const sansObjet = compilationDe({ + priorites: PRIORITES_PAR_DEFAUT, + etatCriteres: etatSous({ ...TOUTES_COCHEES, rencontrerAnimateurs: true }, 'places'), + modeObjectifs: 'places', + }); + for (const compilation of [decoche, sansObjet]) { + const noms = compilation.paliers.flatMap(({ criteres }) => criteres.map(({ nom }) => nom)); + assert.deepEqual(noms, ['ecartAuPlafondAPrioriMax', 'excedentCollisions', 'collisionsCumulees', 'rencontresRepetees', 'redondance']); + } + }); +}); + +// Les modules JavaScript et les composants de src/, hors épreuves, chemins +// relatifs à src/, dans l'ordre alphabétique. Lisant les composants par le +// disque, cette épreuve se relance à chaque composant changé +// (RELANCES_D_UN_COMPOSANT, vitest.config.js). +function sourcesDeSrc() { + const racine = new URL('../', import.meta.url); + return readdirSync(racine, { recursive: true }) + .map((chemin) => String(chemin).split('\\').join('/')) + .filter((chemin) => /\.(js|svelte)$/.test(chemin) && !/\.test\.js$/.test(chemin)) + .sort(); +} + +// Ce que la garde relève dans un texte source : chaque suite de deux noms +// de critères distincts, écrits en chaînes littérales et séparés par une +// seule virgule — le début d'une liste littérale —, et chaque constante +// ORDRE_ déclarée sur une liste littérale de rangs, un ordre de comparaison +// des composantes écrit en dur. Une constante ORDRE_ d'une autre nature — +// l'ordre des simulations de la palette — ne compare aucun critère. +function relevesDeLaGarde(source) { + const noms = CRITERES.join('|'); + const liste = new RegExp(`(['"\`])(${noms})\\1\\s*,\\s*(['"\`])(?!\\2\\3)(${noms})\\3`, 'g'); + return [ + ...[...source.matchAll(liste)].map((trouve) => trouve[0]), + ...[...source.matchAll(/\b(?:const|let|var)\s+(ORDRE_[A-Z_]*)\s*=\s*(?:Object\.freeze\(\s*)?\[\s*\d/g)].map((trouve) => trouve[1]), + ]; +} + +describe('criteres : la garde de la contrainte 35 (registre unique)', () => { + test('la garde relève une liste littérale de noms et une constante ORDRE_, sur des sources inventées', () => { + assert.deepEqual(relevesDeLaGarde("const X = ['excedentCollisions', 'redondance'];"), ["'excedentCollisions', 'redondance'"]); + assert.deepEqual(relevesDeLaGarde('const ORDRE_SCORE = Object.freeze([0, 1]);'), ['ORDRE_SCORE']); + assert.deepEqual(relevesDeLaGarde('const ORDRE_CONTRAINTES = [2, 3, 4, 0, 1, 5];'), ['ORDRE_CONTRAINTES']); + assert.deepEqual(relevesDeLaGarde("const ORDRE_TEINTES = ['rouge', 'vert'];"), []); + assert.equal(relevesDeLaGarde('export const CRITERES = Object.freeze([\n "ecartAuPlafondAPrioriMax",\n "toursHorsObjectif",\n]);').length, 1); + // Un nom seul, une clé de libellé ou une paire nom → même nom ne font pas + // une liste. + assert.deepEqual(relevesDeLaGarde("['redondance', 'totalRedondance'], ['moteur.critere.redondance', 'x']"), []); + assert.deepEqual(relevesDeLaGarde("['excedentCollisions', 'excedentCollisions']"), []); + }); + + test('hors de criteres.js, aucun module de src/ ne porte une liste littérale des noms de critères ni une constante ORDRE_', () => { + const sources = sourcesDeSrc(); + assert.ok(sources.includes('moteur/recherche.js') && sources.includes('moteur/classement.js')); + assert.ok(sources.includes('interface/propositions/SectionPropositions.svelte')); + const releves = sources + .filter((chemin) => chemin !== 'moteur/criteres.js') + .flatMap((chemin) => relevesDeLaGarde(readFileSync(new URL(`../${chemin}`, import.meta.url), 'utf8')).map((r) => `${chemin} : ${r}`)); + assert.deepEqual(releves, []); + // Le registre, lui, en porte : la garde le lirait si elle ne l'écartait. + assert.ok(relevesDeLaGarde(readFileSync(new URL('./criteres.js', import.meta.url), 'utf8')).length > 0); + }); +}); diff --git a/src/moteur/integration.long.test.js b/src/moteur/integration.long.test.js index 9e59455..a74a3a4 100644 --- a/src/moteur/integration.long.test.js +++ b/src/moteur/integration.long.test.js @@ -9,7 +9,10 @@ // d'itinéraire maximal d'au moins 1, porté par au moins 24 mobiles ; un // certificat « minimum atteint » hors d'atteinte. Les seuils de qualité sont // posés après mesure, la mesure en commentaire (§ 14.1). Puis la grille de -// propriétés du § 14.12, sur des configurations tirées. +// propriétés du § 14.12, sur des configurations tirées. Enfin l'ordre réglé +// (A21) : sur cinq graines, l'excédent et les cumulées atteints sous l'ordre +// par défaut et sous un ordre qui range les objectifs au deuxième palier ne +// s'écartent que d'une borne mesurée. import assert from 'node:assert/strict'; import { describe, test } from '../../test/lanceur.js'; import { CATALOGUE } from '../demo/catalogue.js'; @@ -34,6 +37,7 @@ import { scoreIncremental, } from './recherche.js'; import { verifierIndicateurs, verifierInvariants } from './verification.js'; +import { CRITERES } from './criteres.js'; // Compte d'arrêt choisi par la mesure. À 1 000 000 mouvements, chacune des // trois propositions atteint, sur chaque seuil ci-dessous, la meilleure @@ -542,3 +546,53 @@ describe('intégration : grille de propriétés sur des configurations tirées ( assert.deepEqual(sousLePlancher, [], JSON.stringify(comptes)); }); }); + +// La grande démonstration à objectifs : une personne sur trois, dans l'ordre +// de la liste, porte « finance », et une table sur quatre attend « finance » +// pour la soirée. +function grandeAObjectifs(priorites) { + const base = CATALOGUE.find(({ cle }) => cle === 'grande').construire(); + return { + ...base, + participants: base.participants.map((participant, rang) => (rang % 3 === 0 ? { ...participant, etiquettes: ['finance'] } : participant)), + objectifs: base.tables.filter((_, rang) => rang % 4 === 0).map(({ id }) => ({ table: id, tour: null, regle: 'ou', etiquettes: ['finance'] })), + ...(priorites === undefined ? {} : { priorites }), + }; +} + +// Mesure, graines 1 à 5, 100 000 mouvements : sous l'ordre par défaut, +// excédent 3, 0, 1, 0, 0 (somme 4) et cumulées 64, 57, 60, 60, 46 (somme +// 287) ; les objectifs au deuxième palier, excédent 0, 0, 2, 0, 1 (somme 3) +// et cumulées 52, 52, 70, 57, 46 (somme 277). Les bornes laissent la marge +// d'une graine à l'autre, que les sommes ne lissent pas : 5 sur l'excédent, +// 15 % des cumulées de l'ordre par défaut. +const ECART_EXCEDENT_MAX = 5; +const ECART_CUMULEES_RELATIF_MAX = 0.15; + +describe("intégration : l'ordre réglé ne défait pas les collisions (A21)", () => { + test("grande à objectifs, cinq graines : l'excédent et les cumulées sous les objectifs au deuxième palier restent près de ceux de l'ordre par défaut", () => { + const e = (critere) => ({ critere, poids: 1 }); + const deplace = [ + [e('ecartAuPlafondAPrioriMax')], + [e('toursHorsObjectif')], + ...CRITERES.filter((nom) => nom !== 'ecartAuPlafondAPrioriMax' && nom !== 'toursHorsObjectif').map((nom) => [e(nom)]), + ]; + const sommes = [undefined, deplace].map((priorites) => { + const configuration = grandeAObjectifs(priorites); + const instance = normaliser(configuration); + assert.equal(instance.etatCriteres.find(({ nom }) => nom === 'toursHorsObjectif').sansObjet, false); + let excedent = 0; + let cumulees = 0; + for (const graine of [1, 2, 3, 4, 5]) { + const [{ plan }] = rechercher(configuration, { graine, arret: 100_000, nombre: 1 }); + const mesures = mesurer(instance, indexerPlan(instance, plan)); + excedent += mesures.excedentCollisions; + cumulees += mesures.collisionsCumulees; + } + return { excedent, cumulees }; + }); + const [defaut, objectifsDevant] = sommes; + assert.ok(Math.abs(objectifsDevant.excedent - defaut.excedent) <= ECART_EXCEDENT_MAX, JSON.stringify(sommes)); + assert.ok(Math.abs(objectifsDevant.cumulees - defaut.cumulees) <= ECART_CUMULEES_RELATIF_MAX * defaut.cumulees, JSON.stringify(sommes)); + }); +}); diff --git a/src/moteur/integration.test.js b/src/moteur/integration.test.js index b62b5e9..10d4d30 100644 --- a/src/moteur/integration.test.js +++ b/src/moteur/integration.test.js @@ -109,8 +109,10 @@ describe('intégration : de la configuration au classement (§ 5.5, § 5.7, § 1 const generation = generer(demo('petite'), 'petite', REGLAGES); const { classement, premiere } = classerPropositions(generation); // Le plafond a priori est connu : aucun critère n'est sauté, et le - // certificat se lit contre lui (§ 5.5). - assert.deepEqual(classement.criteresAppliques, [...CRITERES]); + // certificat se lit contre lui (§ 5.5). Les critères appliqués, palier + // après palier, aplatis en noms, sont ceux de l'ordre fixe de référence. + const appliques = classement.criteresAppliques.flatMap(({ criteres }) => criteres.map(({ nom }) => nom)); + assert.deepEqual(appliques, [...CRITERES]); assert.equal(classement.critereSaute, null); assert.equal(minimumAtteint(premiere.mesures, generation.aPriori), true); // Le certificat ne lit que les rencontres : il se décerne aussi à un plan diff --git a/src/moteur/recherche.js b/src/moteur/recherche.js index 8d146b7..3bbe7bd 100644 --- a/src/moteur/recherche.js +++ b/src/moteur/recherche.js @@ -36,18 +36,23 @@ // rien. Sinon les deux occupants s'échangent : un échange avec une place // fantôme déplace une personne vers le siège vide d'une autre table. // -// Score. Un n-uplet de six entiers comparé dans l'ordre lexicographique, la -// forme exacte d'une pondération strictement hiérarchique, sans poids ni -// perte de précision : -// 0. le plus grand écart au plafond a priori, plafond a priori − rencontres ; -// 1. la somme des carrés de ces écarts ; -// 2. l'excédent de collisions, si separerAppartenances ; -// 3. les collisions cumulées, si separerAppartenances ; -// 4. Σ_paires max(0, M − 1), M les rencontres de la paire, si -// nouveauxVoisins, plus les retours choisis, si nouvelleTable ; -// 5. Σ_p r(p), la redondance d'appartenance, si varierAppartenances. -// Une composante désactivée vaut 0 ; aucune n'est négative, et le n-uplet -// nul ne s'améliore plus. +// Score. Un n-uplet d'entiers comparé dans l'ordre lexicographique, la forme +// exacte d'une pondération strictement hiérarchique, sans perte de +// précision. Sa forme est la compilation de la liste des priorités de +// l'instance, sous son mode des objectifs (compilationDe, criteres.js) : une +// composante par palier, dans l'ordre des paliers, et plusieurs pour un +// critère seul à son palier qui en descend plusieurs — l'écart au plafond a +// priori, son plus grand écart puis la somme des carrés des écarts ; les +// tours hors objectif, une à trois grandeurs selon le mode. Chaque composante +// vaut Σ poids × grandeur sur les critères de son palier, la grandeur lue +// sous son nom dans l'état ; les rencontres répétées y valent Σ_paires +// max(0, M − 1), M les rencontres de la paire, sous nouveauxVoisins, plus les +// retours choisis sous nouvelleTable. Un critère décoché ou sans objet ne +// pose aucune composante (§ 5.7, D10) : sous l'ordre par défaut, sans +// objectif ni case des animateurs, le n-uplet est celui de l'ordre fixe de +// référence, ses composantes toujours nulles en moins, ce qui ne change +// aucune comparaison. Aucune n'est négative, et le n-uplet nul ne s'améliore +// plus. // // Incrémental. L'état tient à jour ce dont le score dépend : la matrice des // rencontres par paire ; rencontres(p) ; l'histogramme des écarts au plafond @@ -56,10 +61,29 @@ // les visites par (personne, table), d'où les retours choisis ; par // personne, le nombre d'affiliés rencontrés de chaque appartenance, d'où // |F(p)|, A(p) et Σ r(p). Un échange ne touche que les paires des deux tables -// concernées, au tour concerné : O(capacité). Ce score sert la boucle de -// recherche et rien d'autre ; scoreComplet recompose le même n-uplet à partir -// de la mesure qui fait foi, dont il lit chaque grandeur au lieu de la -// refaire (§ 5.10, § 13.2). +// concernées, au tour concerné : O(capacité). +// +// Les objectifs (A13). Les tours servis s(p) suivent la personne déplacée : +// la table de correspondance par objectif distinct dit si la table quittée +// et la table rejointe la servent, et s(p) change d'au plus un. Les tours hors +// objectif H changent de un ; les personnes jamais servies, sur la transition +// de s(p) entre 0 et 1 ; un histogramme des P(p) − s(p) des personnes +// concernées donne le plus grand E, qui ne bouge que de un, comme le plus +// grand écart, et le nombre de personnes à E, nul quand E l'est. O(1) par +// personne déplacée ; les quatre valeurs se tiennent sous tout mode. +// +// Les animateurs (D7, A13). Qui anime au tour r y est fixé : seul le déplacé +// change ses rencontres d'animateurs. Il quitte les animateurs de sa table +// de départ à ce tour et rejoint ceux de sa table d'arrivée, lui-même +// exclu ; un compteur par couple (personne, animateur) dit quand une +// rencontre apparaît ou disparaît, et m(p), avec les animateurs non +// rencontrés Σ (B(p) − m(p)), suit. Partager la table d'un animateur à un +// tour où il n'anime pas n'y compte pas : modifierPaire n'y touche pas. +// O(animateurs des deux tables), au plus O(capacité). +// +// Ce score sert la boucle de recherche et rien d'autre ; scoreComplet +// recompose le même n-uplet à partir de la mesure qui fait foi, dont il lit +// chaque grandeur au lieu de la refaire (§ 5.10, § 13.2). // // Acceptation tardive. L'historique H, de longueur L, part du score initial. // À l'itération i, le candidat est gardé s'il ne dépasse ni le score courant @@ -69,19 +93,23 @@ // courant est le n-uplet nul : sur un compte, jamais sur une durée (§ 5.10). // // Deux ordres d'acceptation. L'acceptation compare d'abord dans l'ordre du -// score, rencontres en tête. Quand la meilleure affectation porte un écart -// maximal nul, chacun atteint son plafond a priori : les deux premières -// composantes sont à leur minimum prouvé (§ 5.5), et une meilleure -// affectation ne peut plus différer que sur les suivantes. L'acceptation -// compare alors les composantes 2, 3 et 4, celles des contraintes, avant les -// deux premières ; l'historique garde son contenu. L'ordre des contraintes -// laisse perdre une rencontre le temps de défaire une collision. L'ordre du -// score le refuse dès que l'historique ne porte plus que des scores à écart -// nul : collisions, voisins et tables n'y progressent plus que par les rares -// échanges qui conservent toutes les rencontres, et s'arrêtent loin de leur -// minimum. La meilleure affectation, jugée dans l'ordre du score, ne retient -// que des affectations à écart nul. Une instance dont aucun plan n'annule -// tous les écarts garde l'ordre du score de bout en bout. +// score, celui des paliers. Quand la meilleure affectation porte un écart +// maximal nul — lu au rang que la compilation donne au plus grand écart —, +// chacun atteint son plafond a priori : les composantes de l'écart sont à +// leur minimum prouvé (§ 5.5), et une meilleure affectation ne peut plus +// différer que sur les autres. L'acceptation compare alors dans l'ordre des +// contraintes de la compilation, où le palier de l'écart descend juste avant +// le premier palier « raffinement » qui le suit ; l'historique garde son +// contenu. Sous l'ordre par défaut, les composantes de l'excédent, des +// cumulées, des tours hors objectif et des rencontres répétées y passent +// devant celles de l'écart. L'ordre des contraintes laisse perdre une +// rencontre le temps de défaire une collision. L'ordre du score le refuse +// dès que l'historique ne porte plus que des scores à écart nul : collisions, +// voisins et tables n'y progressent plus que par les rares échanges qui +// conservent toutes les rencontres, et s'arrêtent loin de leur minimum. La +// meilleure affectation, jugée dans l'ordre du score, ne retient que des +// affectations à écart nul. Une instance dont aucun plan n'annule tous les +// écarts garde l'ordre du score de bout en bout. // // Relance. Dans l'ordre des contraintes, un score courant inchangé pendant // PATIENCE × L itérations fait relancer la descente : le reste du compte @@ -99,10 +127,12 @@ import { LIBRE, RESERVE, SANS_GROUPE, + SANS_OBJECTIF, nombrePlacesManquantes, normaliser, planDepuisIndex, } from './configuration.js'; +import { compilationDe, composantesDe } from './criteres.js'; import { ErreurAnnulee, ErreurConfiguration } from './erreurs.js'; import { mesurer } from './indicateurs.js'; import { ecartsAuPlafondAPriori } from './manque.js'; @@ -134,6 +164,16 @@ import { plafondsAPriori } from './plafond.js'; * @property {number} excedentCollisions collisions cumulées − paires distinctes * @property {number} totalRetoursChoisis * @property {number} totalRedondance Σ_p r(p) + * @property {Int32Array} servis s(p), tours servis + * @property {number} horsObjectif H, sur les personnes concernées + * @property {number} jamaisServies J + * @property {number} horsObjectifMax E + * @property {number} personnesAuMax concernés à E ; 0 quand E = 0 + * @property {Int32Array} rencontresAnimateurs m(p) + * @property {number} animateursNonRencontres Σ (B(p) − m(p)) + * @property {Object} composition la forme du n-uplet de l'instance : + * taille, termes de chaque composante, ordres de comparaison et + * rang du plus grand écart * * @typedef {{tour: number, a: number, b: number}} Mouvement * l'échange des occupants des sièges a et b au tour d'index tour @@ -141,12 +181,6 @@ import { plafondsAPriori } from './plafond.js'; // Siège sans occupant : la place fantôme. const FANTOME = -1; -const TAILLE_SCORE = 6; -// Ordres de comparaison du n-uplet, par rang de composante : celui du score, -// et celui des contraintes, qui passe les composantes 2, 3 et 4 devant les -// deux premières. -const ORDRE_SCORE = Object.freeze([0, 1, 2, 3, 4, 5]); -const ORDRE_CONTRAINTES = Object.freeze([2, 3, 4, 0, 1, 5]); // Itérations entre deux appels de progression, et entre deux lectures du // signal. const CADENCE = 1_000; @@ -158,6 +192,103 @@ const MOT_MAX = 0xffffffff; // Plus grand compte de rencontres d'une paire que tient un Uint16Array : une // paire se réunit au plus une fois par tour. const UINT16_MAX = 0xffff; +// Plus grand compte que tient un Uint8Array : une personne partage la table +// d'un animateur, là où il anime, au plus une fois par tour. +const UINT8_MAX = 0xff; + +// Les grandeurs de l'état que le n-uplet compose, chacune par un code : la +// composition lit un code par terme, et ne parcourt les clés d'aucun objet. +const ECART = 0; +const CARRES = 1; +const EXCEDENT = 2; +const CUMULEES = 3; +const HORS_OBJECTIF = 4; +const JAMAIS_SERVIES = 5; +const HORS_OBJECTIF_MAX = 6; +const PERSONNES_AU_MAX = 7; +const EXCEDENT_PAIRES = 8; +const RETOURS = 9; +const ANIMATEURS = 10; +const REDONDANCE = 11; + +// Le code de chaque grandeur que composantesDe nomme (criteres.js), les +// rencontres répétées mises à part : leur valeur dépend des cases. La Map ne +// sert qu'à retrouver un code par son nom. +const CODE_DE = new Map([ + ['ecartAuPlafondAPrioriMax', ECART], + ['carresEcartsAuPlafondAPriori', CARRES], + ['excedentCollisions', EXCEDENT], + ['collisionsCumulees', CUMULEES], + ['toursHorsObjectif', HORS_OBJECTIF], + ['jamaisServies', JAMAIS_SERVIES], + ['horsObjectifMax', HORS_OBJECTIF_MAX], + ['personnesAuMax', PERSONNES_AU_MAX], + ['animateursNonRencontres', ANIMATEURS], + ['redondance', REDONDANCE], +]); + +// La grandeur de code code, lue sous son nom dans q : l'état de recherche, +// ou les grandeurs que scoreComplet tire de la mesure. +function lire(q, code) { + switch (code) { + case ECART: return q.ecartAuPlafondAPrioriMax; + case CARRES: return q.carresEcartsAuPlafondAPriori; + case EXCEDENT: return q.excedentCollisions; + case CUMULEES: return q.collisionsCumulees; + case HORS_OBJECTIF: return q.horsObjectif; + case JAMAIS_SERVIES: return q.jamaisServies; + case HORS_OBJECTIF_MAX: return q.horsObjectifMax; + case PERSONNES_AU_MAX: return q.personnesAuMax; + case EXCEDENT_PAIRES: return q.excedentPaires; + case RETOURS: return q.totalRetoursChoisis; + case ANIMATEURS: return q.animateursNonRencontres; + default: return q.totalRedondance; + } +} + +// La composition du n-uplet d'une instance, tirée de sa compilation : pour la +// composante k, les termes de debut[k] à debut[k + 1], chacun un code de +// grandeur et un poids ; puis les deux ordres de comparaison et le rang du +// plus grand écart (en-tête du module). Un critère seul à son palier pose un +// terme par composante qu'il descend ; un palier partagé, un terme par +// critère dans sa composante. Les rencontres répétées posent un terme par +// case cochée de « nouveaux voisins » et « nouvelle table », au même poids. +// Lève RangeError pour une grandeur que la composition ne sait pas lire. +function composition(instance) { + const { paliers, ordreScore, ordreContraintes, rangEcart, taille } = compilationDe(instance); + const { contraintes, modeObjectifs } = instance; + const parComposante = Array.from({ length: taille }, () => []); + const poser = (k, nom, poids) => { + if (nom !== 'rencontresRepetees') { + const code = CODE_DE.get(nom); + if (code === undefined) throw new RangeError(`grandeur du score inconnue : ${nom}`); + parComposante[k].push([code, poids]); + return; + } + if (contraintes.nouveauxVoisins) parComposante[k].push([EXCEDENT_PAIRES, poids]); + if (contraintes.nouvelleTable) parComposante[k].push([RETOURS, poids]); + }; + for (const { criteres, composantes } of paliers) { + if (criteres.length === 1) { + const [{ nom, poids }] = criteres; + composantesDe(nom, modeObjectifs).forEach((grandeur, i) => poser(composantes[i], grandeur, poids)); + } else { + for (const { nom, poids } of criteres) poser(composantes[0], nom, poids); + } + } + const debut = new Int32Array(taille + 1); + for (let k = 0; k < taille; k += 1) debut[k + 1] = debut[k] + parComposante[k].length; + const termes = parComposante.flat(); + return { + taille, + debut, + codes: Int32Array.from(termes, ([code]) => code), + poids: Float64Array.from(termes, ([, poids]) => poids), + ordreScore: Int32Array.from(ordreScore), + ordreContraintes: Int32Array.from(ordreContraintes), + rangEcart, + }; +} // Valeur citée dans un message : un nombre tel quel, toute autre valeur // suivie de son type. @@ -189,9 +320,10 @@ function exigerChampsProposition({ graine, arret, historique }) { // Ce que toutes les propositions d'une instance partagent : la disposition // des sièges, les sièges fixés et les sièges mobiles de chaque tour, les // personnes libres de chaque tour par index croissant, le plafond a priori -// de chacun et, par (personne, table), le seuil de visites au-delà duquel -// une visite est un retour choisi. Lève PLACES_MANQUANTES quand les sièges -// d'un tour ne suffisent pas à asseoir tout le monde (§ 5.9). +// de chacun, par (personne, table), le seuil de visites au-delà duquel une +// visite est un retour choisi, et la composition du n-uplet. Lève +// PLACES_MANQUANTES quand les sièges d'un tour ne suffisent pas à asseoir +// tout le monde (§ 5.9). function preparer(instance) { const placesManquantes = nombrePlacesManquantes(instance); if (placesManquantes > 0) throw new ErreurConfiguration('PLACES_MANQUANTES', { placesManquantes }); @@ -267,6 +399,7 @@ function preparer(instance) { seuilRetour, aPriori, largeurHistogramme: plusGrand + 1, + composition: composition(instance), }; } @@ -333,6 +466,69 @@ function modifierPaire(etat, a, b, sens) { if (etat.porteAppartenance[a] === 1) changerAffilie(etat, b, ga, sens); } +// p gagne (sens = 1) ou perd (sens = −1) un tour servi ; p est concerné, et +// P(p) − s(p) reste dans 0..P(p). Les tours hors objectif changent de −sens ; +// les jamais servies, quand s(p) passe de 0 à 1 ou de 1 à 0. Une baisse de +// P(p) − s(p) ne fait baisser le plus grand E que d'un : quand E perd son +// dernier porteur, E − 1 en a un. Le nombre de personnes à E se relit dans +// l'histogramme, et vaut 0 quand E est nul. +function changerServis(etat, p, sens) { + const { servis, histogrammeObjectifs } = etat; + const avant = etat.possibles[p] - servis[p]; + const apres = avant - sens; + servis[p] += sens; + histogrammeObjectifs[avant] -= 1; + histogrammeObjectifs[apres] += 1; + etat.horsObjectif -= sens; + if (servis[p] === (sens > 0 ? 1 : 0)) etat.jamaisServies -= sens; + if (apres > etat.horsObjectifMax) { + etat.horsObjectifMax = apres; + } else if (avant === etat.horsObjectifMax && histogrammeObjectifs[avant] === 0) { + etat.horsObjectifMax = apres; + } + etat.personnesAuMax = etat.horsObjectifMax === 0 ? 0 : histogrammeObjectifs[etat.horsObjectifMax]; +} + +// 1 quand la table t sert p au tour r : un objectif y est en vigueur, et p +// lui correspond ; 0 sinon. +function sert(etat, p, t, r) { + const o = etat.objectifDe[t * etat.R + r]; + return o !== SANS_OBJECTIF && etat.correspond[p * etat.O + o] === 1 ? 1 : 0; +} + +// p rejoint (sens = 1) ou quitte (sens = −1) les animateurs qui animent à la +// table t au tour r, lui-même exclu : une rencontre apparaît quand le compte +// du couple passe de 0 à 1, disparaît quand il revient à 0. +function changerAnimateurs(etat, p, t, r, sens) { + const { debutAnimes, animes, rencontresAnimees, A } = etat; + const ligne = t * etat.R + r; + const soi = etat.rangAnimateur[p]; + for (let k = debutAnimes[ligne], fin = debutAnimes[ligne + 1]; k < fin; k += 1) { + const f = animes[k]; + if (f === soi) continue; + const i = p * A + f; + const avant = rencontresAnimees[i]; + rencontresAnimees[i] = avant + sens; + if (avant === (sens > 0 ? 0 : 1)) { + etat.rencontresAnimateurs[p] += sens; + etat.animateursNonRencontres -= sens; + } + } +} + +// p, libre au tour r, passe de la table depart à la table arrivee : ses tours +// servis et ses rencontres d'animateurs suivent (en-tête du module). +function changerTable(etat, p, depart, arrivee, r) { + if (etat.O > 0) { + const ecart = sert(etat, p, arrivee, r) - sert(etat, p, depart, r); + if (ecart !== 0) changerServis(etat, p, ecart); + } + if (etat.A > 0) { + changerAnimateurs(etat, p, depart, r, -1); + changerAnimateurs(etat, p, arrivee, r, 1); + } +} + // Ajoute sens aux visites de p à la table t, et ajuste les retours choisis. function changerVisite(etat, p, t, sens) { const i = p * etat.T + t; @@ -356,8 +552,8 @@ function voisiner(etat, p, base, t, exclu, sens) { // Échange les occupants des sièges a et b, de deux tables différentes, au // tour r. Chaque occupant quitte d'abord ses voisins, puis rejoint ceux de -// l'autre table : l'échange est sa propre réciproque, et le refaire rend -// l'état d'avant, compteurs compris. +// l'autre table, ses tours servis et ses animateurs avec : l'échange est sa +// propre réciproque, et le refaire rend l'état d'avant, compteurs compris. function echanger(etat, r, a, b) { const { siege, tableDe, tableDuSiege, R } = etat; const base = r * etat.S; @@ -374,20 +570,28 @@ function echanger(etat, r, a, b) { changerVisite(etat, x, ta, -1); changerVisite(etat, x, tb, 1); voisiner(etat, x, base, tb, b, 1); + changerTable(etat, x, ta, tb, r); } if (y !== FANTOME) { tableDe[y * R + r] = ta; changerVisite(etat, y, tb, -1); changerVisite(etat, y, ta, 1); voisiner(etat, y, base, ta, a, 1); + changerTable(etat, y, tb, ta, r); } } // Placement initial d'une instance préparée : les personnes fixées à leurs // sièges, les libres de chaque tour mélangées puis assises dans l'ordre des -// sièges mobiles. Les compteurs partent de zéro et suivent chaque paire et -// chaque visite par les mêmes fonctions que les mouvements ; les écarts -// partent du plafond a priori, aucune rencontre n'étant encore comptée. +// sièges mobiles. Les compteurs partent de zéro et suivent chaque paire, +// chaque visite et chaque rencontre d'animateur par les mêmes fonctions que +// les mouvements ; les écarts partent du plafond a priori, aucune rencontre +// n'étant encore comptée, et les animateurs non rencontrés de Σ B(p). Les +// tours servis se comptent siège par siège, puis l'histogramme des +// P(p) − s(p) se forme sur les personnes concernées. Chaque personne assise +// l'est à une table qu'elle peut occuper — sa table fixée, ou un siège +// mobile, que seule une table à siège libre offre — : servie et rencontres +// d'animateurs s'y lisent sans autre condition, comme mesurer les lit. function etatInitial(structure, rng) { const { instance, N, T, R, S, mobiles, libres, aPriori, tableDuSiege, debut } = structure; const G = instance.groupes.length; @@ -428,11 +632,34 @@ function etatInitial(structure, rng) { excedentPaires: 0, totalRetoursChoisis: 0, totalRedondance: 0, + // Les objectifs : la correspondance par objectif distinct et P(p), de + // l'instance ; s(p) et l'histogramme des P(p) − s(p), de cet état. + O: instance.O, + objectifDe: instance.objectifDe, + correspond: instance.correspond, + possibles: instance.possibles, + servis: new Int32Array(N), + histogrammeObjectifs: new Int32Array(R + 1), + horsObjectif: 0, + jamaisServies: 0, + horsObjectifMax: 0, + personnesAuMax: 0, + // Les animateurs : qui anime à chaque table et chaque tour, de + // l'instance ; le compte de chaque couple (personne, animateur), m(p) et + // Σ (B(p) − m(p)), de cet état. + A: instance.A, + rangAnimateur: instance.rangAnimateur, + debutAnimes: instance.debutAnimes, + animes: instance.animes, + rencontresAnimees: R <= UINT8_MAX ? new Uint8Array(N * instance.A) : new Uint32Array(N * instance.A), + rencontresAnimateurs: new Int32Array(N), + animateursNonRencontres: 0, }; for (let p = 0; p < N; p += 1) { etat.histogramme[aPriori[p]] += 1; etat.carresEcartsAuPlafondAPriori += aPriori[p] * aPriori[p]; etat.ecartAuPlafondAPrioriMax = Math.max(etat.ecartAuPlafondAPrioriMax, aPriori[p]); + etat.animateursNonRencontres += instance.borneAnimateurs[p]; } const { siege, tableDe } = etat; for (let r = 0; r < R; r += 1) { @@ -451,31 +678,42 @@ function etatInitial(structure, rng) { const q = siege[base + voisin]; if (q !== FANTOME) modifierPaire(etat, p, q, 1); } + if (etat.O > 0) etat.servis[p] += sert(etat, p, t, r); + if (etat.A > 0) changerAnimateurs(etat, p, t, r, 1); } } + for (let p = 0; p < N; p += 1) { + const P = etat.possibles[p]; + if (P === 0) continue; + const hors = P - etat.servis[p]; + etat.histogrammeObjectifs[hors] += 1; + etat.horsObjectif += hors; + if (etat.servis[p] === 0) etat.jamaisServies += 1; + etat.horsObjectifMax = Math.max(etat.horsObjectifMax, hors); + } + etat.personnesAuMax = etat.horsObjectifMax === 0 ? 0 : etat.histogrammeObjectifs[etat.horsObjectifMax]; return etat; } // Écrit dans sortie le n-uplet du score, à partir des grandeurs nommées de -// q, et le rend. Une seule composition sert le score tenu à jour et le score +// q, et le rend : chaque composante, Σ poids × grandeur sur ses termes +// (composition). Une seule composition sert le score tenu à jour et le score // recalculé ; une grandeur que mesurer rend y porte son nom, et la -// composition n'en refait aucune. -function composer(contraintes, q, sortie) { - const separer = contraintes.separerAppartenances; - sortie[0] = q.ecartAuPlafondAPrioriMax; - sortie[1] = q.carresEcartsAuPlafondAPriori; - sortie[2] = separer ? q.excedentCollisions : 0; - sortie[3] = separer ? q.collisionsCumulees : 0; - sortie[4] = (contraintes.nouveauxVoisins ? q.excedentPaires : 0) - + (contraintes.nouvelleTable ? q.totalRetoursChoisis : 0); - sortie[5] = contraintes.varierAppartenances ? q.totalRedondance : 0; +// composition n'en refait aucune, ni ne tient aucun cumul pondéré. +function composer({ taille, debut, codes, poids }, q, sortie) { + for (let k = 0; k < taille; k += 1) { + let somme = 0; + for (let j = debut[k], fin = debut[k + 1]; j < fin; j += 1) somme += poids[j] * lire(q, codes[j]); + sortie[k] = somme; + } return sortie; } // Signe de la comparaison lexicographique des n-uplets a[ia …] et b[ib …], -// composantes prises dans l'ordre que donne la liste ordre. +// composantes prises dans l'ordre que donne la liste ordre, une par +// composante. function comparer(ordre, a, ia, b, ib) { - for (let j = 0; j < TAILLE_SCORE; j += 1) { + for (let j = 0; j < ordre.length; j += 1) { const k = ordre[j]; const difference = a[ia + k] - b[ib + k]; if (difference !== 0) return difference; @@ -484,14 +722,15 @@ function comparer(ordre, a, ia, b, ib) { } // Ordre d'acceptation que fixe la meilleure affectation : celui des -// contraintes dès que son écart maximal est nul (en-tête du module). Cet -// écart ne fait que baisser d'une meilleure affectation à la suivante : une -// fois nul, il le reste. -const ordreAccepte = (meilleurScore) => - meilleurScore[0] === 0 ? ORDRE_CONTRAINTES : ORDRE_SCORE; +// contraintes dès que son écart maximal, au rang que la composition donne au +// plus grand écart, est nul (en-tête du module) ; celui du score quand +// l'écart ne compte pas. Cet écart ne fait que baisser d'une meilleure +// affectation à la suivante : une fois nul, il le reste. +const ordreAccepte = ({ rangEcart, ordreScore, ordreContraintes }, meilleurScore) => + rangEcart !== null && meilleurScore[rangEcart] === 0 ? ordreContraintes : ordreScore; function estNul(score) { - for (let k = 0; k < TAILLE_SCORE; k += 1) if (score[k] !== 0) return false; + for (let k = 0; k < score.length; k += 1) if (score[k] !== 0) return false; return true; } @@ -511,44 +750,45 @@ function lireSignal(signal) { // propositions précédentes : à chaque multiple de CADENCE, // progression(fait, total), puis une lecture du signal. function descendre(structure, rng, { arret, historique, dejaFaits, total, signal, progression }) { - const { contraintes } = structure.instance; + const { composition: forme } = structure; + const { taille, ordreScore, ordreContraintes } = forme; let etat = etatInitial(structure, rng); - const courant = composer(contraintes, etat, new Float64Array(TAILLE_SCORE)); - const candidat = new Float64Array(TAILLE_SCORE); + const courant = composer(forme, etat, new Float64Array(taille)); + const candidat = new Float64Array(taille); const meilleurScore = Float64Array.from(courant); const meilleurPlan = Int32Array.from(etat.tableDe); - const memoire = new Float64Array(historique * TAILLE_SCORE); + const memoire = new Float64Array(historique * taille); const remplirMemoire = () => { - for (let v = 0; v < memoire.length; v += TAILLE_SCORE) memoire.set(courant, v); + for (let v = 0; v < memoire.length; v += taille) memoire.set(courant, v); }; // Garde l'état courant comme meilleure affectation s'il la dépasse dans // l'ordre du score, et rend l'ordre d'acceptation qui en découle. const retenir = (ordre) => { - if (comparer(ORDRE_SCORE, courant, 0, meilleurScore, 0) >= 0) return ordre; + if (comparer(ordreScore, courant, 0, meilleurScore, 0) >= 0) return ordre; meilleurScore.set(courant); meilleurPlan.set(etat.tableDe); - return ordreAccepte(meilleurScore); + return ordreAccepte(forme, meilleurScore); }; remplirMemoire(); - let ordre = ordreAccepte(meilleurScore); + let ordre = ordreAccepte(forme, meilleurScore); const patience = PATIENCE * historique; let immobile = 0; for (let i = 0; i < arret && !estNul(courant); i += 1) { - if (immobile >= patience && ordre === ORDRE_CONTRAINTES) { + if (immobile >= patience && ordre === ordreContraintes) { etat = etatInitial(structure, rng); - composer(contraintes, etat, courant); + composer(forme, etat, courant); remplirMemoire(); ordre = retenir(ordre); immobile = 0; } const mouvement = proposerEtAppliquer(etat, rng); - const v = (i % historique) * TAILLE_SCORE; + const v = (i % historique) * taille; let change = false; if (mouvement !== null) { - composer(contraintes, etat, candidat); + composer(forme, etat, candidat); if (comparer(ordre, candidat, 0, courant, 0) <= 0 || comparer(ordre, candidat, 0, memoire, v) <= 0) { - change = comparer(ORDRE_SCORE, candidat, 0, courant, 0) !== 0; + change = comparer(ordreScore, candidat, 0, courant, 0) !== 0; courant.set(candidat); ordre = retenir(ordre); } else { @@ -726,7 +966,8 @@ export function regenerer(configuration, { graine, arret, historique }) { /** * État de recherche au placement initial (§ 5.10) : fixés à leurs tables, * libres mélangés par rng.melanger, tour après tour, puis assis table après - * table dans l'ordre des tables. Lève ErreurConfiguration('PLACES_MANQUANTES', + * table dans l'ordre des tables. L'état porte la composition du n-uplet de + * l'instance. Lève ErreurConfiguration('PLACES_MANQUANTES', * { placesManquantes }) comme rechercher. * * @param {import('./types.js').Instance} instance @@ -734,17 +975,21 @@ export function regenerer(configuration, { graine, arret, historique }) { * @returns {EtatRecherche} */ export function creerEtat(instance, rng) { - return etatInitial(preparer(instance), rng); + const structure = preparer(instance); + const etat = etatInitial(structure, rng); + etat.composition = structure.composition; + return etat; } /** - * Le n-uplet du score, tel que l'état le tient à jour. + * Le n-uplet du score, tel que l'état le tient à jour, de la longueur que la + * compilation de l'instance lui donne. * * @param {EtatRecherche} etat * @returns {number[]} */ export function scoreIncremental(etat) { - return composer(etat.contraintes, etat, new Array(TAILLE_SCORE)); + return composer(etat.composition, etat, new Array(etat.composition.taille)); } /** @@ -752,9 +997,10 @@ export function scoreIncremental(etat) { * qui fait foi, dont chaque grandeur se lit au lieu de se refaire : les * écarts au plafond a priori par ecartsAuPlafondAPriori (manque.js), sur les * plafonds de plafondsAPriori ; l'excédent de collisions, les collisions - * cumulées, les retours choisis et Σ r(p) par mesurer. Une redéfinition de - * l'une d'elles dans son module change donc ce score, et son accord avec le - * score tenu à jour tombe. + * cumulées, les retours choisis, Σ r(p), les quatre valeurs des objectifs et + * les animateurs non rencontrés par mesurer — nulles quand la mesure n'a + * personne de concerné. Une redéfinition de l'une d'elles dans son module + * change donc ce score, et son accord avec le score tenu à jour tombe. * * Seule Σ_paires max(0, M − 1), que la mesure ne rend pas, se calcule ici : * elle vaut Σ_paires M − Σ_paires min(M, 1). Le premier terme compte les @@ -786,6 +1032,7 @@ export function scoreComplet(etat) { for (const o of occupation) reunions += (o * (o - 1)) / 2; let sommeRencontres = 0; for (const rencontres of mesures.rencontres) sommeRencontres += rencontres; + const objectifs = mesures.objectifs ?? { horsObjectif: 0, jamaisServies: 0, horsObjectifMax: 0, personnesAuMax: 0 }; const grandeurs = { ecartAuPlafondAPrioriMax, carresEcartsAuPlafondAPriori, @@ -794,8 +1041,13 @@ export function scoreComplet(etat) { excedentPaires: reunions - sommeRencontres / 2, totalRetoursChoisis: mesures.totalRetoursChoisis, totalRedondance: mesures.totalRedondance, + horsObjectif: objectifs.horsObjectif, + jamaisServies: objectifs.jamaisServies, + horsObjectifMax: objectifs.horsObjectifMax, + personnesAuMax: objectifs.personnesAuMax, + animateursNonRencontres: mesures.animateurs?.nonRencontres ?? 0, }; - return composer(instance.contraintes, grandeurs, new Array(TAILLE_SCORE)); + return composer(etat.composition, grandeurs, new Array(etat.composition.taille)); } /** diff --git a/src/moteur/recherche.long.test.js b/src/moteur/recherche.long.test.js index f110be2..fc0b5aa 100644 --- a/src/moteur/recherche.long.test.js +++ b/src/moteur/recherche.long.test.js @@ -7,7 +7,11 @@ // 1 000 et 10 000 mouvements appliqués, et non au seul terme : une dérive qui // se compense passe inaperçue à la fin, et le palier où un désaccord affleure // est le chiffre que l'épreuve rend. Le relevé liste chaque désaccord, palier -// et grandeur, au lieu de s'arrêter au premier. La petite démonstration et sa +// et grandeur, au lieu de s'arrêter au premier. La même suite se joue sur une +// configuration inventée qui porte des objectifs de table — de soirée et de +// tour, des personnes aux tours possibles inégaux — et des animateurs — l'un +// d'un seul tour, une table à deux titres —, les deux critères cochés, sous +// chacun des trois modes et sous un ordre déplacé (A13). La petite démonstration et sa // variante conflit atteignent ensuite leur optimum sur vingt graines, trois // propositions chacune ; une recherche longue sur la grande démonstration // rend des plans que jugent verifierInvariants et verifierIndicateurs. @@ -27,6 +31,7 @@ import { scoreIncremental, } from './recherche.js'; import { verifierIndicateurs, verifierInvariants } from './verification.js'; +import { differences, empreintesDesPlans, exigerVerification, lireEmpreintes } from '../../test/empreintes_moteur.js'; const PALIERS = [1, 10, 100, 1_000, 10_000]; // Graine des suites de mouvements, écrite ici. @@ -60,8 +65,123 @@ function relever(etat, instance, palier, releve) { confronter('excedentCollisions', [etat.excedentCollisions], [mesures.excedentCollisions]); confronter('totalRetoursChoisis', [etat.totalRetoursChoisis], [mesures.totalRetoursChoisis]); confronter('totalRedondance', [etat.totalRedondance], [mesures.totalRedondance]); + if (mesures.objectifs !== null) { + const { tours, horsObjectif, jamaisServies, horsObjectifMax, personnesAuMax } = mesures.objectifs; + confronter('servis', Array.from(etat.servis), tours); + confronter( + 'objectifs', + [etat.horsObjectif, etat.jamaisServies, etat.horsObjectifMax, etat.personnesAuMax], + [horsObjectif, jamaisServies, horsObjectifMax, personnesAuMax], + ); + } + if (mesures.animateurs !== null) { + confronter('rencontresAnimateurs', Array.from(etat.rencontresAnimateurs), mesures.animateurs.rencontres); + confronter('animateursNonRencontres', [etat.animateursNonRencontres], [mesures.animateurs.nonRencontres]); + } } +// La suite déterministe de mouvements sur une instance, relevée à chaque +// palier : rend le relevé, et les comptes de mouvements appliqués et défaits. +function suivre(instance) { + const rng = creerPcg32(GRAINE, FLUX.RECHERCHE); + const etat = creerEtat(instance, rng); + const releve = []; + relever(etat, instance, 0, releve); + let appliques = 0; + let defaits = 0; + let palier = 0; + for (let essai = 0; palier < PALIERS.length && essai < ESSAIS_MAX; essai += 1) { + const mouvement = proposerEtAppliquer(etat, rng); + if (mouvement === null) continue; + appliques += 1; + if (appliques % DEFAIT_TOUS_LES === 0) { + defaire(etat, mouvement); + defaits += 1; + } + if (appliques === PALIERS[palier]) { + relever(etat, instance, appliques, releve); + palier += 1; + } + } + return { releve, appliques, defaits }; +} + +// Une entrée de palier. +const e = (critere, poids = 1) => ({ critere, poids }); + +// Une soirée inventée de quatorze personnes, quatre tables de 4, quatre +// tours, les six cases cochées. Objectifs : table 1 « finance » pour la +// soirée ; table 2 « finance ET Montréal » pour la soirée, « Montréal » au +// tour 2 ; table 3 « bénévole » au tour 3. 1 à 3 portent « finance », 4 et 5 +// « finance » et « Montréal », 6 et 7 « Montréal », 8 et 9 « bénévole ». 6 +// est réservé à la table 1 au tour 1, 1 à la table 4 au tour 4 : leurs tours +// possibles sont inégaux. 2 est réservé à la table 1 au tour 3, 4 à la table +// 2 au tour 1 : deux places réservées qui servent (D5), l'une sous « ou », +// l'autre sous « et ». 10 anime à la table 1 à chaque tour ; 11 et 12 +// tiennent les deux titres de la table 3 au seul tour 2 ; 13 est réservé +// sans animer. +function soireeAObjectifs(mode, priorites) { + const etiquettes = [ + ['finance'], ['finance'], ['finance'], ['Montréal', 'finance'], ['Montréal', 'finance'], + ['Montréal'], ['Montréal'], ['bénévole'], ['bénévole'], + ]; + const anime = new Map([[10, [1, 2, 3, 4]], [11, [2]], [12, [2]]]); + const auTour = (participant, table, tour) => ({ participant, table, portee: 'tour', tour }); + return { + participants: Array.from({ length: 14 }, (_, rang) => ({ + id: rang + 1, + appartenance: ['A', 'B', 'C', null][rang % 4], + ...(rang < etiquettes.length ? { etiquettes: etiquettes[rang] } : {}), + ...(anime.has(rang + 1) ? { anime: anime.get(rang + 1) } : {}), + })), + tables: [1, 2, 3, 4].map((id) => ({ id, capacite: 4 })), + tours: 4, + reservations: [ + { participant: 10, table: 1, portee: 'tous' }, + auTour(11, 3, 2), auTour(12, 3, 2), auTour(6, 1, 1), auTour(1, 4, 4), auTour(13, 2, 3), + auTour(2, 1, 3), auTour(4, 2, 1), + ], + contraintes: { + separerAppartenances: true, + nouveauxVoisins: true, + nouvelleTable: true, + varierAppartenances: true, + respecterObjectifs: true, + rencontrerAnimateurs: true, + }, + objectifs: [ + { table: 1, tour: null, regle: 'ou', etiquettes: ['finance'] }, + { table: 2, tour: null, regle: 'et', etiquettes: ['Montréal', 'finance'] }, + { table: 2, tour: 2, regle: 'ou', etiquettes: ['Montréal'] }, + { table: 3, tour: 3, regle: 'ou', etiquettes: ['bénévole'] }, + ], + ...(mode === 'places' ? {} : { modeObjectifs: mode }), + ...(priorites === undefined ? {} : { priorites }), + }; +} + +// Les ordres déplacés : sous places, le palier { excédent × 1, tours hors +// objectif × 3 } en deuxième ; sous les deux autres modes, les tours hors +// objectif seuls en deuxième. +const ORDRES_DEPLACES = { + places: [ + [e('ecartAuPlafondAPrioriMax')], + [e('excedentCollisions'), e('toursHorsObjectif', 3)], + [e('collisionsCumulees')], + [e('rencontresRepetees')], + [e('animateursNonRencontres', 2)], + [e('redondance')], + ], + autres: [ + [e('ecartAuPlafondAPrioriMax')], + [e('toursHorsObjectif')], + [e('excedentCollisions')], + [e('collisionsCumulees')], + [e('rencontresRepetees')], + [e('animateursNonRencontres'), e('redondance', 4)], + ], +}; + describe('recherche : incrémental contre recalcul complet (§ 5.10, § 14.10)', () => { test('le catalogue fournit les quatre démonstrations éprouvées ci-dessous', () => { assert.equal(CATALOGUE.length, 4); @@ -69,32 +189,24 @@ describe('recherche : incrémental contre recalcul complet (§ 5.10, § 14.10)', for (const { cle, construire } of CATALOGUE) { test(`${cle} : accord à 1, 10, 100, 1 000 et 10 000 mouvements`, () => { - const instance = normaliser(construire()); - const rng = creerPcg32(GRAINE, FLUX.RECHERCHE); - const etat = creerEtat(instance, rng); - const releve = []; - relever(etat, instance, 0, releve); - let appliques = 0; - let defaits = 0; - let palier = 0; - for (let essai = 0; palier < PALIERS.length && essai < ESSAIS_MAX; essai += 1) { - const mouvement = proposerEtAppliquer(etat, rng); - if (mouvement === null) continue; - appliques += 1; - if (appliques % DEFAIT_TOUS_LES === 0) { - defaire(etat, mouvement); - defaits += 1; - } - if (appliques === PALIERS[palier]) { - relever(etat, instance, appliques, releve); - palier += 1; - } - } + const { releve, appliques, defaits } = suivre(normaliser(construire())); assert.equal(appliques, PALIERS.at(-1), `${cle} : mouvements appliqués`); assert.equal(defaits, Math.floor(PALIERS.at(-1) / DEFAIT_TOUS_LES)); assert.deepEqual(releve, [], cle); }); } + + for (const mode of ['places', 'chacun', 'minimum']) { + for (const [ordre, priorites] of [['ordre par défaut', undefined], ['ordre déplacé', mode === 'places' ? ORDRES_DEPLACES.places : ORDRES_DEPLACES.autres]]) { + test(`soirée à objectifs et animateurs, ${mode}, ${ordre} : accord à 1, 10, 100, 1 000 et 10 000 mouvements`, () => { + const instance = normaliser(soireeAObjectifs(mode, priorites)); + assert.ok(instance.etatCriteres.every(({ coche, sansObjet }) => coche && !sansObjet)); + const { releve, appliques } = suivre(instance); + assert.equal(appliques, PALIERS.at(-1)); + assert.deepEqual(releve, [], `${mode}, ${ordre}`); + }); + } + } }); // Graines 0 à GRAINES − 1, trois propositions chacune : l'optimum se juge sur @@ -154,3 +266,15 @@ describe('recherche : recherche longue sur la grande démonstration (§ 14.12)', } }); }); + +describe('recherche : le mode ne touche pas au défaut, grande démonstration et sa variante (A21)', () => { + exigerVerification(process.env); + const REFERENCE = lireEmpreintes(); + for (const mode of ['chacun', 'minimum']) { + for (const cle of ['grande', 'grande-sans-exception']) { + test(`${cle}, ${mode} : les plans des empreintes de référence`, () => { + assert.deepEqual(differences(`${cle}, ${mode}`, empreintesDesPlans(cle, undefined, mode), REFERENCE.plans[cle]), []); + }); + } + } +}); diff --git a/src/moteur/recherche.test.js b/src/moteur/recherche.test.js index e284548..283ff0a 100644 --- a/src/moteur/recherche.test.js +++ b/src/moteur/recherche.test.js @@ -17,10 +17,15 @@ // « Acceptation tardive », récrite de même, là où ni bascule ni relance ne // jouent, et sinon augmentée de la bascule et de la relance de son // paragraphe « Deux ordres d'acceptation » ; le score tenu à jour et le score -// recalculé égalent un n-uplet recalculé ici par énumération des paires ; la -// petite démonstration atteint son plan parfait sans cas particulier. Un plan -// rendu se juge par verifierInvariants et se mesure par mesurer, deux modules -// distincts du module éprouvé. +// recalculé égalent un n-uplet recalculé ici par énumération des paires, des +// tours servis et des animateurs rencontrés, composé palier par palier sur +// la compilation de l'instance ; la petite démonstration atteint son plan +// parfait sans cas particulier. Sur une configuration inventée à objectifs et +// animateurs, sous chacun des trois modes et sous un ordre réglé, l'état tenu +// à jour s'accorde au recalcul ; la bascule suit l'ordre réglé ; chaque mode +// descend jusqu'aux planchers du diagnostic, et le mode ou l'ordre ne +// changent aucun tirage. Un plan rendu se juge par verifierInvariants et se +// mesure par mesurer, deux modules distincts du module éprouvé. import assert from 'node:assert/strict'; import { describe, test } from '../../test/lanceur.js'; import { CATALOGUE } from '../demo/catalogue.js'; @@ -33,7 +38,9 @@ import { normaliser, planDepuisIndex, } from './configuration.js'; -import { ErreurAnnulee } from './erreurs.js'; +import { CRITERES, compilationDe, composantesDe } from './criteres.js'; +import { diagnostiquer } from './diagnostic.js'; +import { ErreurAnnulee, ErreurConfiguration } from './erreurs.js'; import { mesurer } from './indicateurs.js'; import { plafondsAPriori } from './plafond.js'; import { @@ -48,6 +55,14 @@ import { verifierReglages, } from './recherche.js'; import { verifierInvariants } from './verification.js'; +import { + BASCULE, + differences, + empreintesDesCombinaisons, + empreintesDesPlans, + exigerVerification, + lireEmpreintes, +} from '../../test/empreintes_moteur.js'; const SANS_CONTRAINTE = { separerAppartenances: false, @@ -188,15 +203,63 @@ function placementLitteral(instance, rng) { return tableDe; } -// Le n-uplet du paragraphe « Score » de recherche.js, recalculé par -// énumération directe des -// paires du plan indexé, sans mesurer ni le module éprouvé ; les plafonds a -// priori sont la seule grandeur reçue. Rend aussi les sept grandeurs brutes, -// contraintes ignorées : écart maximal au plafond a priori, somme des carrés -// des écarts, excédent de collisions, collisions cumulées, Σ_paires -// max(0, M − 1), retours choisis, Σ_p r(p). -function scoreOracle(instance, tableDe, aPriori) { - const { N, T, R, groupe, fixe, contraintes } = instance; +// Les tours servis et les tours possibles de chaque personne présente, lus +// directement sur la configuration (§ 4.5) : à la table t au tour r, +// l'objectif du tour remplace celui de la soirée ; une personne y correspond +// sous « ou » quand elle porte l'une de ses étiquettes, sous « et » quand +// elle les porte toutes. (p, r) est possible quand sa table fixée lui +// correspond, ou, p libre, quand une table qui garde un siège libre lui +// correspond ; servi quand p est assis à une table qui lui correspond. +function objectifsDirects(configuration, instance, tableDe) { + const { N, T, R, idsTables, ids, capacite, fixe } = instance; + const objectifs = configuration.objectifs ?? []; + const etiquettesDe = new Map(configuration.participants.map(({ id, etiquettes = [] }) => [id, etiquettes])); + const objectifA = (t, r) => + objectifs.find(({ table, tour }) => table === idsTables[t] && tour === r + 1) + ?? objectifs.find(({ table, tour }) => table === idsTables[t] && (tour === null || tour === undefined)) + ?? null; + const correspond = (p, t, r) => { + const objectif = objectifA(t, r); + if (objectif === null) return false; + const siennes = etiquettesDe.get(ids[p]); + return objectif.regle === 'ou' + ? objectif.etiquettes.some((motCle) => siennes.includes(motCle)) + : objectif.etiquettes.length > 0 && objectif.etiquettes.every((motCle) => siennes.includes(motCle)); + }; + const possibles = Array(N).fill(0); + const servis = Array(N).fill(0); + for (let r = 0; r < R; r += 1) { + const libres = Array.from(capacite); + for (let p = 0; p < N; p += 1) if (fixe[p * R + r] !== LIBRE) libres[fixe[p * R + r]] -= 1; + for (let p = 0; p < N; p += 1) { + const t = fixe[p * R + r]; + const tables = t === LIBRE ? [...Array(T).keys()].filter((u) => libres[u] > 0) : [t]; + if (tables.some((u) => correspond(p, u, r))) possibles[p] += 1; + if (correspond(p, tableDe[p * R + r], r)) servis[p] += 1; + } + } + return { possibles, servis }; +} + +// Les animateurs distincts que chaque personne présente rencontre, lus +// directement : f, autre que p, est rencontré quand p partage sa table à un +// tour où f anime — participants[].anime de la configuration. +function animateursRencontres(configuration, instance, tableDe) { + const { N, R, indexDe } = instance; + const animes = configuration.participants + .filter(({ anime, exclu }) => anime !== undefined && exclu !== true) + .map(({ id, anime }) => ({ f: indexDe.get(id), tours: anime })); + return Array.from({ length: N }, (_, p) => + animes.filter(({ f, tours }) => f !== p && tours.some((tour) => tableDe[f * R + tour - 1] === tableDe[p * R + tour - 1])).length); +} + +// Les grandeurs brutes d'un plan indexé, chacune sous son nom, recalculées +// par énumération directe des paires, des tours servis et des animateurs +// rencontrés, sans mesurer ni le module éprouvé ; les plafonds a priori et +// les bornes des animateurs de l'instance sont les seules grandeurs reçues. +// Les contraintes n'y entrent pas. +function grandeursBrutes(configuration, instance, tableDe, aPriori) { + const { N, T, R, groupe, fixe, borneAnimateurs } = instance; // M[a × N + b], a < b : le nombre de tours où a et b partagent une table. const M = new Int32Array(N * N); for (let r = 0; r < R; r += 1) { @@ -247,32 +310,66 @@ function scoreOracle(instance, tableDe, aPriori) { } } const ecarts = aPriori.map((plafond, p) => plafond - rencontres[p]); - const brutes = [ - Math.max(...ecarts), - ecarts.reduce((somme, ecart) => somme + ecart * ecart, 0), - cumulees - distinctes, - cumulees, + // Les objectifs, sur les personnes concernées, P(p) > 0. + const { possibles, servis } = objectifsDirects(configuration, instance, tableDe); + const hors = possibles.flatMap((P, p) => (P > 0 ? [P - servis[p]] : [])); + const horsObjectifMax = Math.max(0, ...hors); + const m = animateursRencontres(configuration, instance, tableDe); + return { + ecartAuPlafondAPrioriMax: Math.max(...ecarts), + carresEcartsAuPlafondAPriori: ecarts.reduce((somme, ecart) => somme + ecart * ecart, 0), + excedentCollisions: cumulees - distinctes, + collisionsCumulees: cumulees, excedentPaires, - retours, - affilies.reduce((somme, f, p) => somme + f - appartenances[p].size, 0), - ]; - const c = contraintes; - const nUplet = [ - brutes[0], - brutes[1], - c.separerAppartenances ? brutes[2] : 0, - c.separerAppartenances ? brutes[3] : 0, - (c.nouveauxVoisins ? brutes[4] : 0) + (c.nouvelleTable ? brutes[5] : 0), - c.varierAppartenances ? brutes[6] : 0, - ]; + retoursChoisis: retours, + redondance: affilies.reduce((somme, f, p) => somme + f - appartenances[p].size, 0), + toursHorsObjectif: hors.reduce((somme, h) => somme + h, 0), + jamaisServies: possibles.filter((P, p) => P > 0 && servis[p] === 0).length, + horsObjectifMax, + personnesAuMax: horsObjectifMax === 0 ? 0 : hors.filter((h) => h === horsObjectifMax).length, + animateursNonRencontres: Array.from(borneAnimateurs).reduce((somme, B, p) => somme + B - m[p], 0), + }; +} + +// La valeur que la recherche descend pour une grandeur de composantesDe : +// pour les rencontres répétées, Σ max(0, M − 1) sous « nouveaux voisins », +// plus les retours choisis sous « nouvelle table » (§ 5.7) ; sinon la +// grandeur brute de ce nom. +const valeurDescendue = (brutes, nom, contraintes) => (nom === 'rencontresRepetees' + ? (contraintes.nouveauxVoisins ? brutes.excedentPaires : 0) + (contraintes.nouvelleTable ? brutes.retoursChoisis : 0) + : brutes[nom]); + +// Le n-uplet du paragraphe « Score » de recherche.js, composé palier par +// palier sur la compilation de l'instance, à partir des grandeurs brutes : +// un critère seul à son palier pose une composante par grandeur qu'il +// descend, son poids multiplié par la grandeur ; un palier partagé, une +// seule, la somme sur ses critères du poids multiplié par la grandeur. +// Rend aussi les grandeurs brutes. +function scoreOracle(instance, tableDe, aPriori, configuration = { participants: [] }) { + const brutes = grandeursBrutes(configuration, instance, tableDe, aPriori); + const { paliers, taille } = compilationDe(instance); + const nUplet = Array(taille).fill(0); + for (const { criteres, composantes } of paliers) { + if (criteres.length === 1) { + const [{ nom, poids }] = criteres; + composantesDe(nom, instance.modeObjectifs).forEach((grandeur, k) => { + nUplet[composantes[k]] = poids * valeurDescendue(brutes, grandeur, instance.contraintes); + }); + } else { + for (const { nom, poids } of criteres) nUplet[composantes[0]] += poids * valeurDescendue(brutes, nom, instance.contraintes); + } + } return { nUplet, brutes }; } -// Ordres de comparaison du n-uplet, par rang de composante : celui du score, -// et celui des contraintes, composantes 2, 3 et 4 devant les deux premières -// (en-tête de recherche.js). -const ORDRE_SCORE = [0, 1, 2, 3, 4, 5]; -const ORDRE_CONTRAINTES = [2, 3, 4, 0, 1, 5]; +// Les deux ordres de comparaison du n-uplet et le rang de la composante du +// plus grand écart, tels que la compilation de l'instance les donne : celui +// du score, et celui des contraintes, qui passe les paliers « contrainte » +// devant celui de l'écart (en-tête de recherche.js). +function ordresDe(instance) { + const { ordreScore, ordreContraintes, rangEcart } = compilationDe(instance); + return { ORDRE_DU_SCORE: ordreScore, ORDRE_DES_CONTRAINTES: ordreContraintes, rangEcart }; +} // Longueurs d'historique sans changement du score courant au-delà desquelles // une descente se relance (en-tête de recherche.js). const PATIENCE = 10; @@ -295,28 +392,30 @@ function comparerDans(ordre, a, b) { // // Sans bascule, l'ordre d'acceptation reste celui du score : c'est // l'acceptation tardive seule, à la lettre (§ 5.10). Avec, il devient l'ordre des contraintes dès que la -// meilleure affectation porte un écart maximal nul ; dans cet ordre, un -// score courant inchangé pendant PATIENCE × L itérations fait repartir la -// descente d'un placement neuf, tiré du même générateur, l'historique à son -// score (en-tête de recherche.js). Rend le plan et trois comptes : bascules, -// relances, et meilleures affectations trouvées après une relance. +// meilleure affectation porte un écart maximal nul, lu au rang que la +// compilation donne au plus grand écart ; dans cet ordre, un score courant +// inchangé pendant PATIENCE × L itérations fait repartir la descente d'un +// placement neuf, tiré du même générateur, l'historique à son score (en-tête +// de recherche.js). Rend le plan et trois comptes : bascules, relances, et +// meilleures affectations trouvées après une relance. function descendreALaLettre(instance, graineDerivee, arret, L, { bascule }) { + const { ORDRE_DU_SCORE, ORDRE_DES_CONTRAINTES, rangEcart } = ordresDe(instance); const rng = creerPcg32(graineDerivee, FLUX.RECHERCHE); let etat = creerEtat(instance, rng); let courant = scoreIncremental(etat); let meilleur = courant; let meilleurPlan = Int32Array.from(etat.tableDe); let H = Array(L).fill(courant); - let ordre = ORDRE_SCORE; + let ordre = ORDRE_DU_SCORE; let immobile = 0; const comptes = { bascules: 0, relances: 0, apresRelance: 0 }; const basculer = () => { - if (!bascule || ordre === ORDRE_CONTRAINTES || meilleur[0] !== 0) return; - ordre = ORDRE_CONTRAINTES; + if (!bascule || ordre === ORDRE_DES_CONTRAINTES || rangEcart === null || meilleur[rangEcart] !== 0) return; + ordre = ORDRE_DES_CONTRAINTES; comptes.bascules += 1; }; const retenir = () => { - if (comparerDans(ORDRE_SCORE, courant, meilleur) >= 0) return; + if (comparerDans(ORDRE_DU_SCORE, courant, meilleur) >= 0) return; meilleur = courant; meilleurPlan = Int32Array.from(etat.tableDe); if (comptes.relances > 0) comptes.apresRelance += 1; @@ -324,7 +423,7 @@ function descendreALaLettre(instance, graineDerivee, arret, L, { bascule }) { }; basculer(); for (let i = 0; i < arret && courant.some((composante) => composante !== 0); i += 1) { - if (immobile >= PATIENCE * L && ordre === ORDRE_CONTRAINTES) { + if (immobile >= PATIENCE * L && ordre === ORDRE_DES_CONTRAINTES) { etat = creerEtat(instance, rng); courant = scoreIncremental(etat); H = Array(L).fill(courant); @@ -337,7 +436,7 @@ function descendreALaLettre(instance, graineDerivee, arret, L, { bascule }) { if (mouvement !== null) { const candidat = scoreIncremental(etat); if (comparerDans(ordre, candidat, courant) <= 0 || comparerDans(ordre, candidat, H[i % L]) <= 0) { - change = comparerDans(ORDRE_SCORE, candidat, courant) !== 0; + change = comparerDans(ORDRE_DU_SCORE, candidat, courant) !== 0; courant = candidat; retenir(); } else { @@ -961,6 +1060,17 @@ describe("rechercher : l'échange avec une place fantôme (paragraphe « Mouveme }); }); +// Les sept grandeurs brutes que descendait la recherche avant l'ordre réglé. +const GRANDEURS_DE_REFERENCE = [ + 'ecartAuPlafondAPrioriMax', + 'carresEcartsAuPlafondAPriori', + 'excedentCollisions', + 'collisionsCumulees', + 'excedentPaires', + 'retoursChoisis', + 'redondance', +]; + describe('score : composition du n-uplet (en-tête de recherche.js)', () => { test('scoreIncremental et scoreComplet égalent le n-uplet énuméré, pour aucune contrainte, chacune seule et toutes', () => { // Score tenu à jour et score recalculé passent par la même composition : @@ -979,12 +1089,12 @@ describe('score : composition du n-uplet (en-tête de recherche.js)', () => { const aPriori = plafondsAPriori(instance); const rng = creerPcg32(5, FLUX.RECHERCHE); const etat = creerEtat(instance, rng); - const nonNulles = Array(7).fill(false); + const nonNulles = Array(GRANDEURS_DE_REFERENCE.length).fill(false); const confronter = (etiquette) => { const { nUplet, brutes } = scoreOracle(instance, etat.tableDe, aPriori); assert.deepStrictEqual(scoreComplet(etat), nUplet, `${nom}, ${etiquette} : scoreComplet`); assert.deepStrictEqual(scoreIncremental(etat), nUplet, `${nom}, ${etiquette} : scoreIncremental`); - for (let k = 0; k < brutes.length; k += 1) if (brutes[k] > 0) nonNulles[k] = true; + GRANDEURS_DE_REFERENCE.forEach((grandeur, k) => { if (brutes[grandeur] > 0) nonNulles[k] = true; }); }; confronter('placement initial'); let appliques = 0; @@ -994,7 +1104,7 @@ describe('score : composition du n-uplet (en-tête de recherche.js)', () => { confronter(`${appliques} mouvements`); } assert.equal(appliques, MOUVEMENTS, nom); - assert.deepEqual(nonNulles, Array(7).fill(true), `${nom} : grandeurs brutes non nulles`); + assert.deepEqual(nonNulles, Array(GRANDEURS_DE_REFERENCE.length).fill(true), `${nom} : grandeurs brutes non nulles`); } }); }); @@ -1060,3 +1170,357 @@ describe('rechercher : bascule et relance, à la lettre (en-tête de recherche.j ); }); }); + +// --- Les objectifs, les animateurs et l'ordre réglé --------------------------- + +const QUATRE_CASES = { ...TOUTES_CONTRAINTES }; +// Une entrée de palier. +const e = (critere, poids = 1) => ({ critere, poids }); +// L'ordre par défaut, chaque critère seul à son palier. +const parDefaut = () => CRITERES.map((nom) => [e(nom)]); + +// Les ordres déplacés de l'épreuve : sous places, le palier { excédent × 1, +// tours hors objectif × 3 } en deuxième ; sous les deux autres modes, les +// tours hors objectif seuls en deuxième. +const ORDRE_DEPLACE = { + places: [ + [e('ecartAuPlafondAPrioriMax')], + [e('excedentCollisions'), e('toursHorsObjectif', 3)], + [e('collisionsCumulees')], + [e('rencontresRepetees')], + [e('animateursNonRencontres', 2)], + [e('redondance')], + ], + deuxValeurs: [ + [e('ecartAuPlafondAPrioriMax')], + [e('toursHorsObjectif')], + [e('excedentCollisions')], + [e('collisionsCumulees')], + [e('rencontresRepetees')], + [e('animateursNonRencontres'), e('redondance', 4)], + ], +}; +const ordreDeplace = (mode) => (mode === 'places' ? ORDRE_DEPLACE.places : ORDRE_DEPLACE.deuxValeurs); + +// Une soirée inventée de quatorze personnes, quatre tables de 4, quatre +// tours, les six cases cochées. Objectifs : la table 1 « finance » pour la +// soirée ; la table 2 « finance ET Montréal » pour la soirée, remplacé au +// tour 2 par « Montréal » ; la table 3 « bénévole » au tour 3. 1 à 3 portent +// « finance », 4 et 5 « finance » et « Montréal », 6 et 7 « Montréal », 8 et +// 9 « bénévole ». Réservations : 6, « Montréal », à la table 1 au tour 1 ; 1, +// « finance », à la table 4, sans objectif, au tour 4 — leurs tours possibles +// sont inégaux ; 2, « finance », à la table 1 au tour 3, et 4, « finance » et +// « Montréal », à la table 2 au tour 1 : deux places réservées qui servent +// (D5), l'une sous « ou », l'autre sous « et ». Animateurs : 10, à la table 1 +// à chaque tour ; 11 et 12, deux titres de la table 3 au seul tour 2 ; 13, +// réservé sans animer. +function soireeAObjectifs({ mode = 'places', priorites } = {}) { + const etiquettes = [ + ['finance'], ['finance'], ['finance'], ['Montréal', 'finance'], ['Montréal', 'finance'], + ['Montréal'], ['Montréal'], ['bénévole'], ['bénévole'], + ]; + const anime = new Map([[10, [1, 2, 3, 4]], [11, [2]], [12, [2]]]); + return { + participants: Array.from({ length: 14 }, (_, rang) => ({ + id: rang + 1, + nom: `P${rang + 1}`, + appartenance: ['A', 'B', 'C', null][rang % 4], + exclu: false, + ...(rang < etiquettes.length ? { etiquettes: etiquettes[rang] } : {}), + ...(anime.has(rang + 1) ? { anime: anime.get(rang + 1) } : {}), + })), + tables: [1, 2, 3, 4].map((id) => ({ id, numero: id, capacite: 4 })), + tours: 4, + reservations: [tous(10, 1), auTour(11, 3, 2), auTour(12, 3, 2), auTour(6, 1, 1), auTour(1, 4, 4), auTour(13, 2, 3), auTour(2, 1, 3), auTour(4, 2, 1)], + contraintes: { ...QUATRE_CASES, respecterObjectifs: true, rencontrerAnimateurs: true }, + objectifs: [ + { table: 1, tour: null, regle: 'ou', etiquettes: ['finance'] }, + { table: 2, tour: null, regle: 'et', etiquettes: ['Montréal', 'finance'] }, + { table: 2, tour: 2, regle: 'ou', etiquettes: ['Montréal'] }, + { table: 3, tour: 3, regle: 'ou', etiquettes: ['bénévole'] }, + ], + ...(mode === 'places' ? {} : { modeObjectifs: mode }), + ...(priorites === undefined ? {} : { priorites }), + }; +} + +// Chaque jeu de l'épreuve d'accord : chaque mode, sous l'ordre par défaut +// puis sous l'ordre déplacé. +const JEUX_A_OBJECTIFS = ['places', 'chacun', 'minimum'].flatMap((mode) => [ + { nom: `${mode}, ordre par défaut`, configuration: soireeAObjectifs({ mode }) }, + { nom: `${mode}, ordre déplacé`, configuration: soireeAObjectifs({ mode, priorites: ordreDeplace(mode) }) }, +]); + +describe('score : objectifs et animateurs, sous chaque mode et sous un ordre déplacé (§ 5.10, A13, A21)', () => { + test('la soirée inventée : objectifs et animateurs ont un objet, les tours possibles sont inégaux', () => { + const instance = normaliser(soireeAObjectifs()); + const actifs = instance.etatCriteres.filter(({ coche, sansObjet }) => coche && !sansObjet).map(({ nom }) => nom); + assert.deepEqual(actifs, CRITERES); + assert.ok(new Set(instance.possibles).size > 2, `tours possibles : ${[...instance.possibles]}`); + assert.ok(instance.borneAnimateurs.some((B) => B > 0)); + // Deux places réservées servent : 2 à la table 1 au tour 3, 4 à la + // table 2 au tour 1. Un état qui ne compterait s(p) que sur les places + // libres s'écarterait de la mesure dès le placement initial. + for (const [id, table, tour] of [[2, 1, 3], [4, 2, 1]]) { + const p = instance.indexDe.get(id); + const t = instance.indexTableDe.get(table); + const r = tour - 1; + assert.equal(instance.fixe[p * instance.R + r], t, `${id} réservé`); + const o = instance.objectifDe[t * instance.R + r]; + assert.notEqual(o, -1, `${id} : objectif en vigueur`); + assert.equal(instance.correspond[p * instance.O + o], 1, `${id} servi`); + } + }); + + test('scoreIncremental et scoreComplet égalent le n-uplet énuméré au départ, à 1, 10, 100 et 1 000 mouvements, et reviennent au départ une fois défaits', () => { + const PALIERS = [1, 10, 100, 1_000]; + for (const { nom, configuration } of JEUX_A_OBJECTIFS) { + const instance = normaliser(configuration); + const aPriori = plafondsAPriori(instance); + const rng = creerPcg32(31, FLUX.RECHERCHE); + const etat = creerEtat(instance, rng); + const depart = scoreIncremental(etat); + const nonNulles = new Set(); + const confronter = (etiquette) => { + const { nUplet, brutes } = scoreOracle(instance, etat.tableDe, aPriori, configuration); + assert.deepStrictEqual(scoreComplet(etat), nUplet, `${nom}, ${etiquette} : scoreComplet`); + assert.deepStrictEqual(scoreIncremental(etat), nUplet, `${nom}, ${etiquette} : scoreIncremental`); + for (const grandeur of ['toursHorsObjectif', 'jamaisServies', 'horsObjectifMax', 'personnesAuMax', 'animateursNonRencontres']) { + if (brutes[grandeur] > 0) nonNulles.add(grandeur); + } + }; + confronter('placement initial'); + const appliques = []; + for (let essai = 0; appliques.length < PALIERS.at(-1) && essai < 100 * PALIERS.at(-1); essai += 1) { + const mouvement = proposerEtAppliquer(etat, rng); + if (mouvement === null) continue; + appliques.push(mouvement); + if (PALIERS.includes(appliques.length)) confronter(`${appliques.length} mouvements`); + } + assert.equal(appliques.length, PALIERS.at(-1), nom); + assert.equal(nonNulles.size, 5, `${nom} : grandeurs des objectifs et des animateurs non nulles ${[...nonNulles]}`); + for (const mouvement of appliques.reverse()) defaire(etat, mouvement); + assert.deepStrictEqual(scoreIncremental(etat), depart, nom); + } + }); + + test('chaque composante a la place que la compilation lui donne : un mode à deux et trois valeurs, un palier pondéré, une somme de poids', () => { + const tailles = JEUX_A_OBJECTIFS.map(({ configuration }) => scoreIncremental(creerEtat(normaliser(configuration), creerPcg32(1, FLUX.RECHERCHE))).length); + assert.deepEqual(tailles, [8, 7, 9, 8, 10, 9]); + }); + + test("l'état tient les grandeurs des objectifs et des animateurs sous les noms de la mesure : tours servis, tours hors objectif, jamais servies, le plus d'une personne et leur nombre, animateurs rencontrés", () => { + for (const mode of ['places', 'minimum']) { + const instance = normaliser(soireeAObjectifs({ mode })); + const rng = creerPcg32(8, FLUX.RECHERCHE); + const etat = creerEtat(instance, rng); + for (let essai = 0; essai < 300; essai += 1) proposerEtAppliquer(etat, rng); + const { objectifs, animateurs } = mesurer(instance, etat.tableDe); + assert.deepEqual(Array.from(etat.servis), objectifs.tours, mode); + assert.deepEqual( + [etat.horsObjectif, etat.jamaisServies, etat.horsObjectifMax, etat.personnesAuMax], + [objectifs.horsObjectif, objectifs.jamaisServies, objectifs.horsObjectifMax, objectifs.personnesAuMax], + mode, + ); + assert.deepEqual(Array.from(etat.rencontresAnimateurs), animateurs.rencontres, mode); + assert.equal(etat.animateursNonRencontres, animateurs.nonRencontres, mode); + } + }); + + test("une rencontre d'animateur ne compte qu'au tour où il anime : partager la table de 11 aux tours 1, 3 et 4 n'en est pas une", () => { + const configuration = soireeAObjectifs(); + const instance = normaliser(configuration); + const etat = creerEtat(instance, creerPcg32(3, FLUX.RECHERCHE)); + const onze = instance.indexDe.get(11); + const { R, N, A } = instance; + const avecOnze = (p, r) => etat.tableDe[p * R + r] === etat.tableDe[onze * R + r]; + // Les personnes assises avec 11 aux tours 1, 3 ou 4, où il n'anime pas, + // et jamais au tour 2, où il anime : aucune ne l'a rencontré comme + // animateur. + const voisins = []; + for (let p = 0; p < N; p += 1) { + if (p !== onze && [0, 2, 3].some((r) => avecOnze(p, r)) && !avecOnze(p, 1)) voisins.push(p); + } + assert.ok(voisins.length > 0); + const rangOnze = instance.rangAnimateur[onze]; + for (const p of voisins) assert.equal(etat.rencontresAnimees[p * A + rangOnze], 0, `personne d'index ${p}`); + const directs = animateursRencontres(configuration, instance, etat.tableDe); + assert.deepEqual(Array.from(etat.rencontresAnimateurs), directs); + }); +}); + +describe("recherche : le mode et l'ordre réglé ne touchent pas au hasard (A13, § 15.5)", () => { + test('même graine : mêmes mouvements tirés, le générateur au même point, quels que soient objectifs, mode et ordre', () => { + const sans = { ...soireeAObjectifs(), objectifs: [], contraintes: { ...QUATRE_CASES } }; + const suites = [sans, soireeAObjectifs({ mode: 'minimum' }), soireeAObjectifs({ mode: 'places', priorites: ordreDeplace('places') })].map((configuration) => { + const rng = creerPcg32(77, FLUX.RECHERCHE); + const etat = creerEtat(normaliser(configuration), rng); + const mouvements = Array.from({ length: 500 }, () => proposerEtAppliquer(etat, rng)); + return { mouvements, suivant: rng.suivant() }; + }); + assert.deepEqual(suites[1], suites[0]); + assert.deepEqual(suites[2], suites[0]); + }); +}); + +describe("rechercher : la bascule suit l'ordre réglé (A11, A21)", () => { + test("l'écart en troisième palier, petite démonstration en conflit, historique 100 : les plans de la réplique, qui bascule au rang de l'écart", () => { + // L'excédent en tête s'annule avant l'écart : deux descentes sur trois + // annulent ensuite l'écart et basculent, la troisième non. Une bascule + // lue sur la première composante ferait basculer les trois. + const priorites = [ + [e('excedentCollisions')], + [e('collisionsCumulees')], + [e('ecartAuPlafondAPrioriMax')], + ...parDefaut().slice(3), + ]; + const configuration = () => ({ ...demo('petite-conflit'), priorites }); + const instance = normaliser(configuration()); + assert.equal(ordresDe(instance).rangEcart, 2); + const reglages = { graine: 4, arret: 20_000, nombre: 3, historique: 100 }; + const repliques = descentesALaLettre(configuration(), reglages, { bascule: true }); + assert.deepStrictEqual( + rechercher(configuration(), reglages).map(({ plan }) => plan), + repliques.map(({ plan }) => plan), + ); + assert.deepEqual(repliques.map(({ bascules }) => bascules), [1, 0, 1]); + }); + + test('la soirée à objectifs sous chaque mode et sous son ordre déplacé : les plans de la réplique', () => { + for (const { nom, configuration } of JEUX_A_OBJECTIFS) { + const reglages = { graine: 9, arret: 3_000, nombre: 2, historique: 50 }; + assert.deepStrictEqual( + rechercher(configuration, reglages).map(({ plan }) => plan), + descentesALaLettre(configuration, reglages, { bascule: true }).map(({ plan }) => plan), + nom, + ); + } + }); +}); + +// Dix personnes « finance », dont 10 d'appartenance A ; 11, sans étiquette, +// d'appartenance A, réservé à la table « finance » de huit places pour la +// soirée ; 12, sans étiquette ni appartenance. Une table sans objectif de +// quatre, quatre tours, la seule case « séparer les appartenances » cochée +// hors des objectifs ; les tours hors objectif au premier palier, les +// collisions cumulées au deuxième, l'écart au troisième. Chaque tour où 10 +// est servi coûte une collision avec 11 sans changer les tours hors +// objectif : sous places, la descente le laisse jamais servi, au plus loin +// de l'objectif ; les deux autres modes doivent payer la collision. +function soireeFinance(mode) { + const premiers = ['toursHorsObjectif', 'collisionsCumulees', 'ecartAuPlafondAPrioriMax']; + return { + participants: [ + ...Array.from({ length: 10 }, (_, rang) => ({ id: rang + 1, nom: `F${rang + 1}`, appartenance: rang === 9 ? 'A' : null, exclu: false, etiquettes: ['finance'] })), + { id: 11, nom: 'G11', appartenance: 'A', exclu: false }, + { id: 12, nom: 'G12', appartenance: null, exclu: false }, + ], + tables: [{ id: 1, numero: 1, capacite: 8 }, { id: 2, numero: 2, capacite: 4 }], + tours: 4, + reservations: [tous(11, 1)], + contraintes: { ...SANS_CONTRAINTE, separerAppartenances: true }, + objectifs: [{ table: 1, tour: null, regle: 'ou', etiquettes: ['finance'] }], + priorites: [...premiers.map((nom) => [e(nom)]), ...CRITERES.filter((nom) => !premiers.includes(nom)).map((nom) => [e(nom)])], + ...(mode === 'places' ? {} : { modeObjectifs: mode }), + }; +} + +describe('rechercher : chaque mode descend jusqu’aux planchers du diagnostic (A21)', () => { + // Les valeurs que chaque mode compare, dans l'ordre, lues dans la mesure et + // dans les planchers du diagnostic ; puis, pour les deux modes qui + // comparent une valeur de plus, celle que la descente sous places laisse + // au-dessus de son plancher. + const COMPAREES = { + places: ['horsObjectif'], + chacun: ['jamaisServies', 'horsObjectif'], + minimum: ['horsObjectifMax', 'horsObjectif'], + }; + const CONTRASTE = { chacun: 'jamaisServies', minimum: 'horsObjectifMax' }; + const PLANCHERS = { horsObjectif: 12, jamaisServies: 0, horsObjectifMax: 2 }; + const objectifsDu = (configuration, graine) => { + const instance = normaliser(configuration); + const [{ plan }] = rechercher(configuration, { graine, arret: 20_000, nombre: 1 }); + return mesurer(instance, indexerPlan(instance, plan)).objectifs; + }; + + test('places : les tours hors objectif dépendent du plan — un placement tiré assied 12 à la table « finance » —, et la descente les ramène à leur plancher', () => { + const configuration = soireeFinance('places'); + assert.deepEqual(diagnostiquer(configuration).objectifsInevitables, PLANCHERS); + const instance = normaliser(configuration); + const departs = [1, 2, 3].map((graine) => creerEtat(instance, creerPcg32(graine, FLUX.RECHERCHE)).horsObjectif); + assert.ok(departs.some((H) => H > PLANCHERS.horsObjectif), `départs : ${departs}`); + for (const graine of [1, 2, 3]) { + const objectifs = objectifsDu(configuration, graine); + for (const valeur of COMPAREES.places) assert.equal(objectifs[valeur], PLANCHERS[valeur], `places, graine ${graine}, ${valeur}`); + // 10 n'est jamais servi : les deux autres modes ont à le corriger. + assert.equal(objectifs.tours[9], 0, `places, graine ${graine}`); + } + }); + + for (const mode of ['chacun', 'minimum']) { + test(`${mode} : à trois graines, chaque valeur que le mode compare atteint son plancher, que la même graine sous places laisse au-dessus`, () => { + const configuration = soireeFinance(mode); + assert.deepEqual(diagnostiquer(configuration).objectifsInevitables, PLANCHERS); + for (const graine of [1, 2, 3]) { + const objectifs = objectifsDu(configuration, graine); + for (const valeur of COMPAREES[mode]) assert.equal(objectifs[valeur], PLANCHERS[valeur], `${mode}, graine ${graine}, ${valeur}`); + const sousPlaces = objectifsDu(soireeFinance('places'), graine); + assert.ok(sousPlaces[CONTRASTE[mode]] > PLANCHERS[CONTRASTE[mode]], `places, graine ${graine}, ${CONTRASTE[mode]}`); + } + }); + } + + test("sous minimum et chacun, la descente s'arrête sur le n-uplet nul : le nombre de personnes au plus grand retombe à 0 avec lui", () => { + // Six « finance », une table sans objectif de deux rangée d'abord, une + // table « finance » de six : le placement initial en assied deux à la + // première ; le plan nul les assied toutes à la seconde. Les tours hors + // objectif au premier palier : seule la personne qui rejoint la seconde + // table un à un améliore le score, l'écart au plafond, lui, se dégrade le + // temps d'un mouvement. + for (const mode of ['minimum', 'chacun']) { + const configuration = { + priorites: [[e('toursHorsObjectif')], [e('ecartAuPlafondAPrioriMax')], ...parDefaut().slice(1).filter(([{ critere }]) => critere !== 'toursHorsObjectif')], + participants: Array.from({ length: 6 }, (_, rang) => ({ id: rang + 1, nom: `F${rang + 1}`, appartenance: null, exclu: false, etiquettes: ['finance'] })), + tables: [{ id: 1, numero: 1, capacite: 2 }, { id: 2, numero: 2, capacite: 6 }], + tours: 1, + reservations: [], + contraintes: { ...SANS_CONTRAINTE }, + objectifs: [{ table: 2, tour: null, regle: 'ou', etiquettes: ['finance'] }], + modeObjectifs: mode, + }; + const instance = normaliser(configuration); + assert.notDeepEqual(scoreIncremental(creerEtat(instance, creerPcg32(1, FLUX.RECHERCHE))).filter((v) => v !== 0), []); + const avancements = []; + const [{ plan }] = rechercher(configuration, { graine: 1, arret: 100_000, nombre: 1 }, { progression: (fait) => avancements.push(fait) }); + assert.deepEqual(avancements, [], mode); + assert.deepEqual(plan.tours[0][1].length, 6, mode); + } + }); + + test('un mode qui compare deux valeurs garde son palier : les tours hors objectif joints à un autre critère refusent PRIORITES', () => { + const priorites = [[e('ecartAuPlafondAPrioriMax')], [e('excedentCollisions'), e('toursHorsObjectif')], ...parDefaut().slice(2).filter(([{ critere }]) => critere !== 'toursHorsObjectif')]; + for (const mode of ['chacun', 'minimum']) { + assert.throws( + () => rechercher(soireeAObjectifs({ mode, priorites }), { graine: 1, arret: 10, nombre: 1 }), + (erreur) => erreur instanceof ErreurConfiguration && erreur.code === 'PRIORITES' && erreur.details.faute === 'seulASonPalier', + ); + } + assert.equal(rechercher(soireeAObjectifs({ priorites }), { graine: 1, arret: 10, nombre: 1 }).length, 1); + }); +}); + +describe('rechercher : le mode ne touche pas au défaut, sans objectif (A21, contrainte 33)', () => { + exigerVerification(process.env); + const REFERENCE = lireEmpreintes(); + for (const mode of ['chacun', 'minimum']) { + test(`${mode} : les plans de la petite et de sa variante, des seize combinaisons et de la bascule sont ceux des empreintes de référence`, () => { + for (const cle of ['petite', 'petite-conflit']) { + assert.deepEqual(differences(`${cle}, ${mode}`, empreintesDesPlans(cle, undefined, mode), REFERENCE.plans[cle]), []); + } + assert.deepEqual(differences(`petite, ${mode}`, empreintesDesCombinaisons(mode), REFERENCE.combinaisons), []); + for (const cle of BASCULE.cles) { + assert.deepEqual(differences(`${cle} (bascule), ${mode}`, empreintesDesPlans(cle, BASCULE.arret, mode), REFERENCE.bascule[cle]), []); + } + }); + } +}); diff --git a/test/documentation.test.js b/test/documentation.test.js index 11985b2..12ff2a3 100644 --- a/test/documentation.test.js +++ b/test/documentation.test.js @@ -400,7 +400,7 @@ describe('documentation : les régions engendrées des documents livrés (§ 19. assert.deepEqual(lireRegions(lire(README)).map((r) => r.nom), ['contrastes', 'constantes']); assert.deepEqual( lireRegions(lire(GUIDE_USAGE)).map((r) => r.nom), - ['reglages', 'colonnes-csv', 'commandes', 'raccourcis', 'demonstrations'], + ['reglages', 'colonnes-csv', 'colonnes-objectifs', 'commandes', 'raccourcis', 'demonstrations'], ); // Chaque producteur sert une région, et une seule. const servies = PORTEURS.flatMap((chemin) => lireRegions(lire(chemin)).map((r) => r.nom)).sort(); diff --git a/test/empreintes_moteur.js b/test/empreintes_moteur.js index 3b85a86..ca18700 100644 --- a/test/empreintes_moteur.js +++ b/test/empreintes_moteur.js @@ -40,6 +40,13 @@ // src/moteur/empreintes.test.js, et seulement sur l'état du moteur que le // fichier doit garder. Une empreinte qui change est un défaut, jamais une // régénération : écrire est un geste conscient (§ 15.5, point 5). +// +// Le mode des objectifs (D20) est un paramètre de chaque calcul : 'places', +// le défaut, laisse la configuration et la charge sans mode ; 'chacun' et +// 'minimum' le leur donnent. Sans objectif, les trois rendent les mêmes +// empreintes : le fichier, relevé sans mode, les garde toutes (A21, « le mode +// ne touche pas au défaut »). Le classement se lit en noms : la liste aplatie +// des critères appliqués, palier après palier. import { createHash } from 'node:crypto'; import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; @@ -49,6 +56,7 @@ import { evaluer } from '../src/application/evaluation.js'; import { CATALOGUE } from '../src/demo/catalogue.js'; import { texteLivre } from '../src/demo/livrees.js'; import { indexerPlan, normaliser } from '../src/moteur/configuration.js'; +import { MODES_OBJECTIFS } from '../src/moteur/criteres.js'; import { mesurer } from '../src/moteur/indicateurs.js'; import { ecartsAuPlafondAPriori } from '../src/moteur/manque.js'; import { plafondsAPriori } from '../src/moteur/plafond.js'; @@ -203,6 +211,18 @@ function configurationDuCatalogue(cle) { return entree.construire(); } +// Lève RangeError pour un mode hors de MODES_OBJECTIFS (criteres.js). +function exigerMode(mode) { + if (!MODES_OBJECTIFS.includes(mode)) throw new RangeError(`mode des objectifs inconnu : ${String(mode)}`); +} + +// La configuration sous ce mode : telle quelle sous 'places', le mode posé +// sinon. +function sousLeMode(configuration, mode) { + exigerMode(mode); + return mode === 'places' ? configuration : { ...configuration, modeObjectifs: mode }; +} + // L'empreinte du plan que rechercher rend pour une configuration sous une // graine, une proposition, à arret mouvements, historique par défaut. function empreinteRecherchee(configuration, graine, arret = ARRET) { @@ -213,14 +233,16 @@ function empreinteRecherchee(configuration, graine, arret = ARRET) { /** * Les empreintes des plans d'une configuration du catalogue, une par graine - * de GRAINES, sous la clé de la graine, à arret mouvements. + * de GRAINES, sous la clé de la graine, à arret mouvements, sous ce mode des + * objectifs. * * @param {string} cle * @param {number} [arret=ARRET] + * @param {'places'|'chacun'|'minimum'} [mode='places'] * @returns {Record} */ -export function empreintesDesPlans(cle, arret = ARRET) { - const configuration = configurationDuCatalogue(cle); +export function empreintesDesPlans(cle, arret = ARRET, mode = 'places') { + const configuration = sousLeMode(configurationDuCatalogue(cle), mode); return Object.fromEntries( GRAINES.map((graine) => [String(graine), empreinteRecherchee(configuration, graine, arret)]), ); @@ -247,28 +269,34 @@ export function combinaisons() { /** * Les empreintes des plans de la petite démonstration sous chacune des seize - * combinaisons, graine 1, sous la clé de la combinaison. + * combinaisons, graine 1, sous la clé de la combinaison, sous ce mode des + * objectifs. * + * @param {'places'|'chacun'|'minimum'} [mode='places'] * @returns {Record} */ -export function empreintesDesCombinaisons() { +export function empreintesDesCombinaisons(mode = 'places') { return Object.fromEntries( combinaisons().map(({ cle, contraintes }) => [ cle, - empreinteRecherchee({ ...configurationDuCatalogue('petite'), contraintes }, GRAINES[0]), + empreinteRecherchee(sousLeMode({ ...configurationDuCatalogue('petite'), contraintes }, mode), GRAINES[0]), ]), ); } /** * La charge du classement de référence : la démonstration livrée de clé - * cle, et la génération de CLASSEMENT enregistrée par la commande. + * cle, sous ce mode des objectifs — le réglage absent sous 'places' —, et la + * génération de CLASSEMENT enregistrée par la commande. * * @param {string} cle + * @param {'places'|'chacun'|'minimum'} [mode='places'] * @returns {import('../src/stockage/types.js').Charge} */ -export function chargeDeClassement(cle) { +export function chargeDeClassement(cle, mode = 'places') { + exigerMode(mode); const charge = analyser(texteLivre(cle)).charge; + if (mode !== 'places') charge.reglages.modeObjectifs = mode; const configuration = configurationDepuisCharge(charge); const { graine, arret, nombre } = CLASSEMENT; const propositions = rechercher(configuration, { graine, arret, nombre }); @@ -281,28 +309,34 @@ export function chargeDeClassement(cle) { /** * Ce que l'empreinte garde d'un classement : l'ordre, les noms des critères - * appliqués dans leur ordre, le critère sauté. + * appliqués dans leur ordre — la liste aplatie, palier après palier —, le + * critère sauté. * - * @param {{ordre: number[], criteresAppliques: string[], critereSaute: string|null}} classement + * @param {{ordre: number[], criteresAppliques: Array<{criteres: Array<{nom: string}>}>, critereSaute: string|null}} classement * @returns {{ordre: number[], criteresAppliques: string[], critereSaute: string|null}} */ export function classementLu({ ordre, criteresAppliques, critereSaute }) { - return { ordre: [...ordre], criteresAppliques: [...criteresAppliques], critereSaute }; + return { + ordre: [...ordre], + criteresAppliques: criteresAppliques.flatMap(({ criteres }) => criteres.map(({ nom }) => nom)), + critereSaute, + }; } /** - * Le classement de référence d'une charge de CLASSEMENT.cles : connu et - * inconnu, ses classements par evaluer, plafond a priori calculé puis - * inconnu ; propositions, les grandeurs nommées de chacune, plafond a priori - * calculé, par identifiant croissant. + * Le classement de référence d'une charge de CLASSEMENT.cles, sous ce mode + * des objectifs : connu et inconnu, ses classements par evaluer, plafond a + * priori calculé puis inconnu ; propositions, les grandeurs nommées de + * chacune, plafond a priori calculé, par identifiant croissant. * * @param {string} cle + * @param {'places'|'chacun'|'minimum'} [mode='places'] * @returns {{connu: object, inconnu: object, propositions: Record>}} * @throws {Error} une proposition qui ne se compare pas : elle n'a pas de * grandeurs */ -export function empreintesDuClassement(cle) { - const charge = chargeDeClassement(cle); +export function empreintesDuClassement(cle, mode = 'places') { + const charge = chargeDeClassement(cle, mode); const connu = evaluer(charge); const inconnu = evaluer(charge, { plafondsAPriori: null }); const ligneDe = new Map(connu.propositions.map((ligne) => [ligne.id, ligne])); diff --git a/vitest.config.js b/vitest.config.js index 49bf8e1..3f4d512 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -57,7 +57,8 @@ const PROCESSUS = { pool: SERIES.pool, maxWorkers: SERIES.maxWorkers }; // garde des lecteurs par le disque (test/arborescence.test.js), textuelle, // les compte parmi les lecteurs des composants. test/documentation.test.js en // est : elle lit ces motifs pour les fichiers que scripts/documentation.js -// lit par le disque. +// lit par le disque. La garde de la contrainte 35 (src/moteur/criteres.test.js) +// lit chaque composant à la recherche d'une liste littérale de critères. const RELANCES_D_UN_COMPOSANT = Object.freeze([ 'test/arborescence.test.js', 'test/sources_interface.test.js', @@ -73,6 +74,7 @@ const RELANCES_D_UN_COMPOSANT = Object.freeze([ 'test/rapporteur.test.js', 'test/documentation.test.js', 'src/stockage/etiquettes.test.js', + 'src/moteur/criteres.test.js', ]); // Les épreuves node qui lisent la feuille des jetons par le système de