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