[ADD] tags and goals: engine measures, CSV column, commands, bridge

Tags on people and goals on tables now reach every layer below the
screens. The engine measures rounds served, never-served people and
facilitators met, with a floor per goal mode at the diagnostic; the CSV
reads and writes an etiquettes column through the shared split and
spelling reconciliation; commands set tags, goals, their rule and the
two optional criteria, each refusal with cause and remedy; facilitators
and the stored priority order reach the engine through one bridge.
Without goals or the facilitator case, plans equal the reference prints.
Checked: node 4373, navigateur 885, node-long 113.

--- FR ---

[ADD] étiquettes et objectifs : mesures, colonne CSV, commandes, pont

Les étiquettes des personnes et les objectifs des tables atteignent toutes
les couches sous les écrans. Le moteur mesure les tours servis, les
personnes jamais servies et les animateurs rencontrés, avec un plancher
par mode au diagnostic ; le CSV lit et écrit une colonne d'étiquettes par
la découpe et la réconciliation communes ; des commandes posent étiquettes,
objectifs, règle et les deux critères facultatifs, chaque refus avec cause
et remède ; animateurs et ordre écrit passent au moteur par un seul pont.
Sans objectif ni case des animateurs, les plans égalent les empreintes.
Vérifié : node 4373, navigateur 885, node-long 113.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-11 15:03:13 -04:00
parent 2d782f919f
commit 320b1b1687
68 changed files with 4895 additions and 635 deletions

View file

@ -561,6 +561,7 @@ majuscules ni aux accents.
| `titre_pressenti` | `titre`, `rôle` |
| `exclu` | — |
| `notes` | — |
| `etiquettes` | `étiquette`, `mots-clés` |
<!-- engendre:fin colonnes-csv -->
@ -575,6 +576,7 @@ l’historique ; les noms des exemples sont inventés.
|---|---|
| `ajouterParticipant` | Ajouté Iris Ombrelle |
| `modifierParticipant` | Modifié Ondine Lacasse : courriel |
| `changerEtiquettes` | Étiquettes de Iris Ombrelle : ajouté « finance » |
| `exclureParticipant` | Exclu Théo Pervenche |
| `reintegrerParticipant` | Réintégré Théo Pervenche |
| `supprimerParticipant` | Supprimé Ondine Lacasse |
@ -593,6 +595,9 @@ l’historique ; les noms des exemples sont inventés.
| `changerTours` | Nombre de tours : 3 ; 2 réservations retirées |
| `modifierReglage` | Séparer les appartenances : désactivé |
| `changerCibleRedondance` | Cible de la somme de redondance : 1200 |
| `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 |
| `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 |

File diff suppressed because one or more lines are too long

View file

@ -1,16 +1,16 @@
Nom;Prénom;Organisation;Courriel;Rôle;Exclu;Notes;Téléphone
Lanterneau;Basile;Chœur de l’Anse;basile.lanterneau@exemple.test;animation;;;555-0101
Pommerel;Capucine;Société des Hérons;capucine.pommerel@exemple.test;;;;555-0102
Marbrelle;Gaspard;Cercle des Saules;;animation;;;555-0103
Sablonnet;Ysaline;Chœur de l’Anse;;;;;555-0104
Givrecœur;Anselme;;anselme.givrecoeur@exemple.test;;;;555-0105
Tourterel;Héloïse;Cercle des Saules;;;oui;S’est désistée la veille;555-0106
Fenouillet;Côme;Société des Hérons;;;;"végétarien; sans noix, ni arachides";555-0107
Nom;Prénom;Organisation;Courriel;Rôle;Exclu;Notes;Étiquettes;Téléphone
Lanterneau;Basile;Chœur de l’Anse;basile.lanterneau@exemple.test;animation;;;"finance, Montréal";555-0101
Pommerel;Capucine;Société des Hérons;capucine.pommerel@exemple.test;;;;bénévole|ressources humaines;555-0102
Marbrelle;Gaspard;Cercle des Saules;;animation;;;Finance;555-0103
Sablonnet;Ysaline;Chœur de l’Anse;;;;;;555-0104
Givrecœur;Anselme;;anselme.givrecoeur@exemple.test;;;;;555-0105
Tourterel;Héloïse;Cercle des Saules;;;oui;S’est désistée la veille;;555-0106
Fenouillet;Côme;Société des Hérons;;;;"végétarien; sans noix, ni arachides";;555-0107
Coudraie;Apolline;Chœur de l’Anse;;;;"Arrive après le premier tour
repart avant le dessert";555-0108
Lanterneau;Basile;Chœur de l’Anse;;;;;555-0109
Brunœil;;Cercle des Saules;brunoeil@exemple.test;;;;555-0110
Ormelin;Faustine;Societe des herons;;;;;555-0111
;Clotilde;Cercle des Saules;clotilde@exemple.test;;;;555-0112
Ardoisier;Edgar;Société des Hérons;;;;;555-0113
Cressonnier;Bertille;Cercle des Saules;;;;;555-0114
repart avant le dessert";;555-0108
Lanterneau;Basile;Chœur de l’Anse;;;;;;555-0109
Brunœil;;Cercle des Saules;brunoeil@exemple.test;;;;;555-0110
Ormelin;Faustine;Societe des herons;;;;;;555-0111
;Clotilde;Cercle des Saules;clotilde@exemple.test;;;;;555-0112
Ardoisier;Edgar;Société des Hérons;;;;;;555-0113
Cressonnier;Bertille;Cercle des Saules;;;;;;555-0114

1 Nom Prénom Organisation Courriel Rôle Exclu Notes Étiquettes Téléphone
2 Lanterneau Basile Chœur de l’Anse basile.lanterneau@exemple.test animation finance, Montréal 555-0101
3 Pommerel Capucine Société des Hérons capucine.pommerel@exemple.test bénévole|ressources humaines 555-0102
4 Marbrelle Gaspard Cercle des Saules animation Finance 555-0103
5 Sablonnet Ysaline Chœur de l’Anse 555-0104
6 Givrecœur Anselme anselme.givrecoeur@exemple.test 555-0105
7 Tourterel Héloïse Cercle des Saules oui S’est désistée la veille 555-0106
8 Fenouillet Côme Société des Hérons végétarien; sans noix, ni arachides 555-0107
9 Coudraie Apolline Chœur de l’Anse Arrive après le premier tour repart avant le dessert 555-0108
10 Lanterneau Basile Chœur de l’Anse 555-0109
11 Brunœil Cercle des Saules brunoeil@exemple.test 555-0110
12 Ormelin Faustine Societe des herons 555-0111
13 Clotilde Cercle des Saules clotilde@exemple.test 555-0112
14 Ardoisier Edgar Société des Hérons 555-0113
15 Cressonnier Bertille Cercle des Saules 555-0114
16

View file

@ -1,16 +1,16 @@
Nom,Prénom,Organisation,Courriel,Rôle,Exclu,Notes,Téléphone
Lanterneau,Basile,Chœur de l’Anse,basile.lanterneau@exemple.test,animation,,,555-0101
Pommerel,Capucine,Société des Hérons,capucine.pommerel@exemple.test,,,,555-0102
Marbrelle,Gaspard,Cercle des Saules,,animation,,,555-0103
Sablonnet,Ysaline,Chœur de l’Anse,,,,,555-0104
Givrecœur,Anselme,,anselme.givrecoeur@exemple.test,,,,555-0105
Tourterel,Héloïse,Cercle des Saules,,,oui,S’est désistée la veille,555-0106
Fenouillet,Côme,Société des Hérons,,,,"végétarien; sans noix, ni arachides",555-0107
Nom,Prénom,Organisation,Courriel,Rôle,Exclu,Notes,Étiquettes,Téléphone
Lanterneau,Basile,Chœur de l’Anse,basile.lanterneau@exemple.test,animation,,,"finance, Montréal",555-0101
Pommerel,Capucine,Société des Hérons,capucine.pommerel@exemple.test,,,,bénévole|ressources humaines,555-0102
Marbrelle,Gaspard,Cercle des Saules,,animation,,,Finance,555-0103
Sablonnet,Ysaline,Chœur de l’Anse,,,,,,555-0104
Givrecœur,Anselme,,anselme.givrecoeur@exemple.test,,,,,555-0105
Tourterel,Héloïse,Cercle des Saules,,,oui,S’est désistée la veille,,555-0106
Fenouillet,Côme,Société des Hérons,,,,"végétarien; sans noix, ni arachides",,555-0107
Coudraie,Apolline,Chœur de l’Anse,,,,"Arrive après le premier tour
repart avant le dessert",555-0108
Lanterneau,Basile,Chœur de l’Anse,,,,,555-0109
Brunœil,,Cercle des Saules,brunoeil@exemple.test,,,,555-0110
Ormelin,Faustine,Societe des herons,,,,,555-0111
,Clotilde,Cercle des Saules,clotilde@exemple.test,,,,555-0112
Ardoisier,Edgar,Société des Hérons,,,,,555-0113
Cressonnier,Bertille,Cercle des Saules,,,,,555-0114
repart avant le dessert",,555-0108
Lanterneau,Basile,Chœur de l’Anse,,,,,,555-0109
Brunœil,,Cercle des Saules,brunoeil@exemple.test,,,,,555-0110
Ormelin,Faustine,Societe des herons,,,,,,555-0111
,Clotilde,Cercle des Saules,clotilde@exemple.test,,,,,555-0112
Ardoisier,Edgar,Société des Hérons,,,,,,555-0113
Cressonnier,Bertille,Cercle des Saules,,,,,,555-0114

1 Nom Prénom Organisation Courriel Rôle Exclu Notes Étiquettes Téléphone
2 Lanterneau Basile Chœur de l’Anse basile.lanterneau@exemple.test animation finance, Montréal 555-0101
3 Pommerel Capucine Société des Hérons capucine.pommerel@exemple.test bénévole|ressources humaines 555-0102
4 Marbrelle Gaspard Cercle des Saules animation Finance 555-0103
5 Sablonnet Ysaline Chœur de l’Anse 555-0104
6 Givrecœur Anselme anselme.givrecoeur@exemple.test 555-0105
7 Tourterel Héloïse Cercle des Saules oui S’est désistée la veille 555-0106
8 Fenouillet Côme Société des Hérons végétarien; sans noix, ni arachides 555-0107
9 Coudraie Apolline Chœur de l’Anse Arrive après le premier tour repart avant le dessert 555-0108
10 Lanterneau Basile Chœur de l’Anse 555-0109
11 Brunœil Cercle des Saules brunoeil@exemple.test 555-0110
12 Ormelin Faustine Societe des herons 555-0111
13 Clotilde Cercle des Saules clotilde@exemple.test 555-0112
14 Ardoisier Edgar Société des Hérons 555-0113
15 Cressonnier Bertille Cercle des Saules 555-0114
16

View file

@ -1,261 +1,261 @@
nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes
Mirvault;Isabelle;Comptoir Gravelune;;;non;
Neskière;Youssef;Verrerie Vaubrenne;;animation;non;
Abrevande;Félix;Imprimerie Yzorne;;;non;
Fralvançon;Hugo;Brasserie Wassorel;;;non;
Fralveux;Manon;Menuiserie Estrolle;;;non;
Luskotte;Catherine;Fonderie Estrolle;;;non;
WAMMERWOLM-MAWWEMMEL;Zoé;Menuiserie Ulvardes;;;non;
Neskichon;Louise;Ateliers Rossolde;;animation;non;
Urvesson;Catherine;Fabrique Ulvardes;;;non;
Hulvivet;Karim;Fonderie Xandrelle;;;non;
Selveron;Caroline;Ateliers Cassorgue;;;non;
Korvault;Catherine;Tannerie Nervanche;;;non;
Fralvotte;Caroline;Fonderie Ivoranche;;;non;
Jusvesson;Geneviève;Tannerie Belvarre;;;non;
Abrevumet;Paul;Ateliers Aubrenc;;;non;
Fralvault;Léa;Fonderie Sorbagne;;;non;
Obrivet;Fatima;Fabrique Sorbagne;;;non;
Voskeval;Philippe;Menuiserie Estrolle;;animation;non;
Dulveval;Zoé;Menuiserie Quimvaux;;;non;
Korveux;Paul;Filature Olbrecq;;;non;
Luskière;Julie;Fabrique Sorbagne;;;non;
Clovande;Émilie;Ateliers Falvigne;;;non;
Hulvande;Paul;Verrerie Vaubrenne;;;non;
Fralvesson;Hugo;Filature Wassorel;;;non;
Urvière;Isabelle;Brasserie Belvarre;;;non;
Rozvotte;Thomas;Verrerie Ivoranche;;;non;
Bralmumet;Isabelle;Verrerie Aubrenc;;;non;
Dulvouze;Catherine;Ateliers Rossolde;;;non;
Voskichon;Marc;Menuiserie Estrolle;;;non;
Mirvivet;Simon;Ateliers Ivoranche;;;non;
Clovault;Louise;Imprimerie Yzorne;;;non;
Voskeux;Lucie;Coopérative Morvalle;;;non;
Eskignac;Amina;Brasserie Xandrelle;;;non;
Korvouze;Fatima;Ateliers Tavrenne;;animation;non;
Selvoubre;Luc;Brasserie Sorbagne;;;non;
Fralvichon;Étienne;Papeterie Ulvardes;;;non;
Voskivet;Philippe;Ateliers Tavrenne;;;non;
Quilmançon;Louise;Menuiserie Estrolle;;;non;
Luskande;Sophie;Fabrique Morvalle;;;non;
Abreveron;Pierre;Ateliers Rossolde;;;non;
Neskeux;Philippe;Comptoir Xandrelle;;;non;
Neskançon;Amina;Fabrique Ulvardes;;;non;
Tolvande;Léa;Ateliers Rossolde;;;non;
Quilmeron;Antoine;Fabrique Kalvenne;;;non;
Clovumet;Guillaume;Verrerie Pravette;;;non;
Eskeux;Michel;Papeterie Wassorel;;;non;
Hulvault;Louise;Coopérative Jolvane;;;non;
Hulveron;Catherine;Coopérative Rossolde;;animation;non;
Ivrivet;François;Menuiserie Sorbagne;;animation;non;
Eskumet;Samuel;Verrerie Ivoranche;;;non;
Fralvivet;Claire;Imprimerie Yzorne;;;non;
Mirveux;Léa;Menuiserie Wassorel;;;non;
Gorzesson;Karim;Papeterie Ulvardes;;;non;
Fralvumet;Jean;Ateliers Falvigne;;animation;non;
Urvançon;Manon;Ateliers Tavrenne;;animation;non;
Quilmande;Sylvie;Imprimerie Yzorne;;;non;
Gorzichon;François;Ateliers Estrolle;;;non;
Pruvesson;Amina;Fonderie Sorbagne;;;non;
Quilmière;Lucie;Fabrique Ivoranche;;;non;
Korvesson;Fatima;Ateliers Rossolde;;;non;
Selvesson;Nicolas;Imprimerie Yzorne;;;non;
Selvivet;Louis;Menuiserie Hostrenne;;animation;non;
Neskesson;Louise;Tannerie Belvarre;;;non;
Tolvesson;Lucie;Filature Zelvaine;;;non;
Quilmesson;Olivier;Comptoir Gravelune;;;non;
Luskivet;Guillaume;Brasserie Lavorneau;;;non;
Neskault;Gabriel;Menuiserie Tavrenne;;;non;
Urvumet;Antoine;Menuiserie Hostrenne;;;non;
Jusvançon;Olivier;Brasserie Morvalle;;;non;
Dulvançon;Simon;Menuiserie Ivoranche;;;non;
Gorzard;Chloé;Fabrique Sorbagne;;;non;
Pruveux;Gabriel;Papeterie Estrolle;;;non;
Clovière;Manon;Ateliers Ivoranche;;;non;
Obrouze;François;Tannerie Olbrecq;;animation;non;
Tolveval;Karim;Tannerie Belvarre;;;non;
Dulvumet;Geneviève;Menuiserie Quimvaux;;animation;non;
Bérêçàuvïlle-Dœûmôntëÿ;Hugo;Ateliers Falvigne;;;non;
Gorzeron;Claire;Menuiserie Hostrenne;;;non;
Luskumet;Hélène;Brasserie Xandrelle;;;non;
Fralvande;Louis;Ateliers Rossolde;;;non;
Selvault;Émilie;Fabrique Sorbagne;;;non;
Neskumet;Léa;Verrerie Aubrenc;;;non;
Korvichon;Isabelle;Menuiserie Ulvardes;;;non;
Obrande;Michel;Menuiserie Estrolle;;;non;
Luskeval;Isabelle;Brasserie Wassorel;;;non;
Tolvoubre;Fatima;Papeterie Ulvardes;;;non;
Gorzière;Zoé;Menuiserie Olbrecq;;;non;
Korvignac;Sylvie;Fabrique Ulvardes;;;non;
Obreval;Antoine;Ateliers Cassorgue;;;non;
Korveron;Michel;Fonderie Sorbagne;;animation;non;
Tolvumet;Camille;Fonderie Estrolle;;;non;
Dulvesson;Marie;Ateliers Cassorgue;;;non;
Jusvard;Youssef;Tannerie Aubrenc;;animation;non;
Tolvignac;Guillaume;Menuiserie Sorbagne;;;non;
Mirvotte;Gabriel;Tannerie Belvarre;;;non;
Urvard;Marie;Filature Wassorel;;animation;non;
Bralmesson;Marie;Coopérative Dravenne;;animation;non;
Neskignac;Paul;Brasserie Sorbagne;;;non;
Bralmeux;Samuel;Ateliers Rossolde;;animation;non;
Ivrotte;Raphaël;Coopérative Rossolde;;;non;
Luskouze;Marc;Ateliers Rossolde;;;non;
Jusvotte;Sylvie;Brasserie Belvarre;;;non;
Bralmoubre;Véronique;Fonderie Xandrelle;;;non;
Selvard;Pierre;Verrerie Vaubrenne;;;non;
Hulveux;Marie;Coopérative Rossolde;;;non;
Pruvotte;Sophie;Imprimerie Yzorne;;animation;non;
Fralveval;Karim;Coopérative Gravelune;;;non;
Gorzignac;Philippe;Imprimerie Falvigne;;;non;
Selvouze;Étienne;Ateliers Tavrenne;;;non;
Hulvichon;Louis;Ateliers Falvigne;;;non;
Urvoubre;Sylvie;Verrerie Vaubrenne;;;non;
Pruveval;Pierre;Comptoir Xandrelle;;;non;
Hulvotte;Guillaume;Verrerie Vaubrenne;;;non;
Abrevignac;Geneviève;Brasserie Morvalle;;;non;
Abrevault;Gabriel;Ateliers Rossolde;;animation;non;
Bralmignac;Amina;Fonderie Estrolle;;;non;
Bralmançon;Olivier;Comptoir Lavorneau;;;non;
Luskoubre;Marc;Verrerie Vaubrenne;;;non;
Abrevoubre;Geneviève;Menuiserie Gravelune;;;non;
Korvoubre;Raphaël;Verrerie Vaubrenne;;;non;
Mirvard;Pierre;Verrerie Ivoranche;;;non;
Jusvumet;Camille;Fonderie Estrolle;;;non;
Cloveval;Samuel;Menuiserie Ulvardes;;;non;
Selvière;Antoine;Coopérative Dravenne;;;non;
Jusvouze;Véronique;Verrerie Vaubrenne;;;non;
Neskivet;Paul;Comptoir Jolvane;;;non;
Dulvande;Samuel;Verrerie Pravette;;;non;
Bralmault;Antoine;Fabrique Sorbagne;;animation;non;
Abrevière;Émilie;Imprimerie Yzorne;;animation;non;
Selveval;Lucie;Filature Zelvaine;;;non;
Tolvichon;Olivier;Fonderie Xandrelle;;;non;
Hulvard;Fatima;Ateliers Estrolle;;;non;
Ivreux;Alexandre;Tannerie Aubrenc;;;non;
Korveval;Félix;Tannerie Nervanche;;;non;
Urvichon;Karim;Tannerie Nervanche;;;non;
Dulvichon;Émilie;Menuiserie Ulvardes;;;non;
Tolvouze;Luc;Fabrique Ivoranche;;;non;
Jusvivet;Claire;Imprimerie Yzorne;;animation;non;
Quilmault;Amina;Coopérative Jolvane;;;non;
Ivrouze;François;Fonderie Aubrenc;;;non;
Gorzumet;Hélène;Fonderie Aubrenc;;;non;
Ivrault;Sophie;Coopérative Morvalle;;;non;
Bralmière;Louise;Tannerie Quimvaux;;;non;
Korvande;Nicolas;Comptoir Sorbagne;;;non;
Ivrumet;Geneviève;Coopérative Jolvane;;;non;
Obresson;Geneviève;Coopérative Gravelune;;;non;
Obrançon;Guillaume;Papeterie Hostrenne;;;non;
Urvignac;Fatima;Ateliers Aubrenc;;;non;
Bralmotte;Claire;Ateliers Tavrenne;;;non;
Hulvignac;Félix;Imprimerie Hostrenne;;;non;
Clovotte;François;Coopérative Rossolde;;;non;
Dulvoubre;Philippe;Brasserie Belvarre;;;non;
Rozveron;Samuel;Fonderie Sorbagne;;;non;
Hulvoubre;Pierre;Menuiserie Olbrecq;;;non;
Urvouze;Émilie;Comptoir Gravelune;;;non;
Korvançon;Félix;Fonderie Ivoranche;;;non;
Neskande;Émilie;Coopérative Gravelune;;;non;
Mirvumet;Chantal;Menuiserie Gravelune;;;non;
Korvotte;Alexandre;Menuiserie Nervanche;;;non;
Eskeval;Zoé;Tannerie Nervanche;;;non;
Neskard;Camille;Menuiserie Gravelune;;animation;non;
Tolveron;Manon;Brasserie Belvarre;;animation;non;
Voskoubre;Nathalie;Fonderie Kalvenne;;;non;
Pruvière;Sophie;Ateliers Rossolde;;;non;
Obrichon;Paul;Ateliers Rossolde;;;non;
Abrevançon;Fatima;Tannerie Nervanche;;animation;non;
Eskière;Nathalie;Fonderie Xandrelle;;;non;
Ivreron;Caroline;Imprimerie Falvigne;;;non;
Neskoubre;Véronique;Fonderie Estrolle;;;non;
Urvivet;Anne;Fonderie Kalvenne;;;non;
Hulvançon;Olivier;Menuiserie Estrolle;;;non;
Clovançon;Karim;Fabrique Sorbagne;;;non;
Quilmotte;Zoé;Ateliers Aubrenc;;;non;
Eskouze;Félix;Filature Zelvaine;;;non;
Rozvichon;Amina;Verrerie Olbrecq;;;non;
Fralvière;Simon;Comptoir Sorbagne;;;non;
Vandergrolcquesmirtavelle-Ostrovaubelindigues;Marc;Tannerie Aubrenc;;animation;non;
Hulvière;Pierre;Papeterie Hostrenne;;;non;
Jusvault;Raphaël;Fabrique Sorbagne;;;non;
Dulvivet;Guillaume;Filature Olbrecq;;animation;non;
Fralvoubre;Pierre;Imprimerie Falvigne;;;non;
Selveux;Hélène;Brasserie Lavorneau;;;non;
Rozvançon;Caroline;Brasserie Belvarre;;;non;
Korvière;Olivier;Ateliers Falvigne;;;non;
Ivrichon;Nicolas;Fabrique Sorbagne;;;non;
Korvumet;Félix;Imprimerie Yzorne;;;non;
Voskande;Félix;Ateliers Falvigne;;animation;non;
Selvumet;Guillaume;Papeterie Estrolle;;;non;
Urvotte;Michel;Verrerie Ivoranche;;;non;
Eskault;Émilie;Coopérative Jolvane;;;non;
Bralmeval;Hélène;Brasserie Dravenne;;;non;
Hulvesson;Guillaume;Comptoir Jolvane;;;non;
Jusvoubre;Caroline;Fabrique Sorbagne;;;non;
Luskeux;Félix;Tannerie Belvarre;;;non;
Pruvouze;Antoine;Imprimerie Yzorne;;;non;
Quilmignac;Julie;Menuiserie Jolvane;;;non;
Tolvançon;Étienne;Menuiserie Estrolle;;animation;non;
Voskumet;Hugo;Ateliers Tavrenne;;;non;
Quilmumet;Manon;Fonderie Sorbagne;;;non;
Voskouze;Marc;Fonderie Estrolle;;;non;
Abrevard;Samuel;Filature Dravenne;;;non;
Luskichon;Guillaume;Brasserie Belvarre;;animation;non;
Clovesson;Manon;Fabrique Sorbagne;;;non;
Hulvumet;Simon;Ateliers Ivoranche;;;non;
Voskeron;Lucie;Verrerie Ivoranche;;;non;
Selvignac;Thomas;Brasserie Sorbagne;;animation;non;
Neskeron;Jean;Filature Zelvaine;;;non;
Pruvivet;Manon;Menuiserie Aubrenc;;;non;
Clovard;Sophie;Brasserie Belvarre;;;non;
Gorzeval;Étienne;Papeterie Ulvardes;;;non;
Ivroubre;Philippe;Ateliers Tavrenne;;animation;non;
Luskault;Gabriel;Verrerie Gravelune;;;non;
Korvard;Émilie;Verrerie Vaubrenne;;;non;
Quilmichon;Thomas;Fonderie Estrolle;;;non;
Eskançon;Alexandre;Menuiserie Hostrenne;;;non;
Luskançon;Émilie;Ateliers Tavrenne;;;non;
Dulvault;Marc;Verrerie Ivoranche;;;non;
Rozveval;Hélène;Comptoir Ivoranche;;;non;
Pruvançon;Julie;Imprimerie Yzorne;;;non;
Mirveron;Louis;Filature Wassorel;;;non;
Eskard;François;Fonderie Xandrelle;;;non;
Bralmouze;Hugo;Tannerie Falvigne;;animation;non;
Ivrançon;Louis;Coopérative Morvalle;;;non;
Clovivet;Karim;Fonderie Kalvenne;;;non;
Rozvande;Léa;Verrerie Olbrecq;;;non;
Hulvouze;Amina;Coopérative Dravenne;;;non;
Eskesson;Étienne;Menuiserie Ulvardes;;;non;
Abreveval;Philippe;Ateliers Falvigne;;;non;
Quilmeux;Émilie;Ateliers Falvigne;;;non;
Ivreval;Gabriel;Verrerie Vaubrenne;;animation;non;
Voskotte;Simon;Fabrique Ivoranche;;;non;
Obrignac;Chantal;Imprimerie Yzorne;;;non;
Eskotte;Guillaume;Verrerie Nervanche;;;non;
Obrard;Léa;Comptoir Sorbagne;;;non;
Gorzivet;Guillaume;Fonderie Estrolle;;;non;
Mirvançon;Geneviève;Brasserie Belvarre;;;non;
Selvotte;Julie;Comptoir Xandrelle;;;non;
Mirvesson;Amina;Brasserie Sorbagne;;;non;
Luskignac;Isabelle;Papeterie Estrolle;;;non;
Obrumet;Chloé;Comptoir Yzorne;;;non;
Abrevesson;Anne;Fonderie Estrolle;;;non;
Rozvumet;Fatima;Menuiserie Hostrenne;;;non;
Mirvoubre;Guillaume;Comptoir Sorbagne;;;non;
Rozvoubre;Véronique;Fonderie Xandrelle;;;non;
Gorzotte;Claire;Menuiserie Olbrecq;;;non;
Ivrière;Antoine;Fabrique Sorbagne;;;non;
Selvichon;Isabelle;Tannerie Nervanche;;;non;
Urvault;Isabelle;Comptoir Yzorne;;;non;
Voskesson;Julie;Brasserie Belvarre;;;non;
Abrevotte;Chantal;Ateliers Ivoranche;;;non;
Neskotte;Amina;Fabrique Kalvenne;;;non;
Voskançon;Étienne;Menuiserie Olbrecq;;;non;
Fralveron;Zoé;Menuiserie Sorbagne;;;non;
Rozvouze;Marc;Ateliers Estrolle;;;non;
Abreveux;Sylvie;Comptoir Jolvane;;;non;
Pruvignac;Karim;Menuiserie Estrolle;;;non;
Mirveval;Zoé;Fabrique Kalvenne;;;non;
Fralvignac;Philippe;Menuiserie Estrolle;;;non;
Gorzeux;Gabriel;Menuiserie Ulvardes;;;non;
Urveval;Jean;Menuiserie Quimvaux;;;non;
nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes;etiquettes
Mirvault;Isabelle;Comptoir Gravelune;;;non;;
Neskière;Youssef;Verrerie Vaubrenne;;animation;non;;
Abrevande;Félix;Imprimerie Yzorne;;;non;;
Fralvançon;Hugo;Brasserie Wassorel;;;non;;
Fralveux;Manon;Menuiserie Estrolle;;;non;;
Luskotte;Catherine;Fonderie Estrolle;;;non;;
WAMMERWOLM-MAWWEMMEL;Zoé;Menuiserie Ulvardes;;;non;;
Neskichon;Louise;Ateliers Rossolde;;animation;non;;
Urvesson;Catherine;Fabrique Ulvardes;;;non;;
Hulvivet;Karim;Fonderie Xandrelle;;;non;;
Selveron;Caroline;Ateliers Cassorgue;;;non;;
Korvault;Catherine;Tannerie Nervanche;;;non;;
Fralvotte;Caroline;Fonderie Ivoranche;;;non;;
Jusvesson;Geneviève;Tannerie Belvarre;;;non;;
Abrevumet;Paul;Ateliers Aubrenc;;;non;;
Fralvault;Léa;Fonderie Sorbagne;;;non;;
Obrivet;Fatima;Fabrique Sorbagne;;;non;;
Voskeval;Philippe;Menuiserie Estrolle;;animation;non;;
Dulveval;Zoé;Menuiserie Quimvaux;;;non;;
Korveux;Paul;Filature Olbrecq;;;non;;
Luskière;Julie;Fabrique Sorbagne;;;non;;
Clovande;Émilie;Ateliers Falvigne;;;non;;
Hulvande;Paul;Verrerie Vaubrenne;;;non;;
Fralvesson;Hugo;Filature Wassorel;;;non;;
Urvière;Isabelle;Brasserie Belvarre;;;non;;
Rozvotte;Thomas;Verrerie Ivoranche;;;non;;
Bralmumet;Isabelle;Verrerie Aubrenc;;;non;;
Dulvouze;Catherine;Ateliers Rossolde;;;non;;
Voskichon;Marc;Menuiserie Estrolle;;;non;;
Mirvivet;Simon;Ateliers Ivoranche;;;non;;
Clovault;Louise;Imprimerie Yzorne;;;non;;
Voskeux;Lucie;Coopérative Morvalle;;;non;;
Eskignac;Amina;Brasserie Xandrelle;;;non;;
Korvouze;Fatima;Ateliers Tavrenne;;animation;non;;
Selvoubre;Luc;Brasserie Sorbagne;;;non;;
Fralvichon;Étienne;Papeterie Ulvardes;;;non;;
Voskivet;Philippe;Ateliers Tavrenne;;;non;;
Quilmançon;Louise;Menuiserie Estrolle;;;non;;
Luskande;Sophie;Fabrique Morvalle;;;non;;
Abreveron;Pierre;Ateliers Rossolde;;;non;;
Neskeux;Philippe;Comptoir Xandrelle;;;non;;
Neskançon;Amina;Fabrique Ulvardes;;;non;;
Tolvande;Léa;Ateliers Rossolde;;;non;;
Quilmeron;Antoine;Fabrique Kalvenne;;;non;;
Clovumet;Guillaume;Verrerie Pravette;;;non;;
Eskeux;Michel;Papeterie Wassorel;;;non;;
Hulvault;Louise;Coopérative Jolvane;;;non;;
Hulveron;Catherine;Coopérative Rossolde;;animation;non;;
Ivrivet;François;Menuiserie Sorbagne;;animation;non;;
Eskumet;Samuel;Verrerie Ivoranche;;;non;;
Fralvivet;Claire;Imprimerie Yzorne;;;non;;
Mirveux;Léa;Menuiserie Wassorel;;;non;;
Gorzesson;Karim;Papeterie Ulvardes;;;non;;
Fralvumet;Jean;Ateliers Falvigne;;animation;non;;
Urvançon;Manon;Ateliers Tavrenne;;animation;non;;
Quilmande;Sylvie;Imprimerie Yzorne;;;non;;
Gorzichon;François;Ateliers Estrolle;;;non;;
Pruvesson;Amina;Fonderie Sorbagne;;;non;;
Quilmière;Lucie;Fabrique Ivoranche;;;non;;
Korvesson;Fatima;Ateliers Rossolde;;;non;;
Selvesson;Nicolas;Imprimerie Yzorne;;;non;;
Selvivet;Louis;Menuiserie Hostrenne;;animation;non;;
Neskesson;Louise;Tannerie Belvarre;;;non;;
Tolvesson;Lucie;Filature Zelvaine;;;non;;
Quilmesson;Olivier;Comptoir Gravelune;;;non;;
Luskivet;Guillaume;Brasserie Lavorneau;;;non;;
Neskault;Gabriel;Menuiserie Tavrenne;;;non;;
Urvumet;Antoine;Menuiserie Hostrenne;;;non;;
Jusvançon;Olivier;Brasserie Morvalle;;;non;;
Dulvançon;Simon;Menuiserie Ivoranche;;;non;;
Gorzard;Chloé;Fabrique Sorbagne;;;non;;
Pruveux;Gabriel;Papeterie Estrolle;;;non;;
Clovière;Manon;Ateliers Ivoranche;;;non;;
Obrouze;François;Tannerie Olbrecq;;animation;non;;
Tolveval;Karim;Tannerie Belvarre;;;non;;
Dulvumet;Geneviève;Menuiserie Quimvaux;;animation;non;;
Bérêçàuvïlle-Dœûmôntëÿ;Hugo;Ateliers Falvigne;;;non;;
Gorzeron;Claire;Menuiserie Hostrenne;;;non;;
Luskumet;Hélène;Brasserie Xandrelle;;;non;;
Fralvande;Louis;Ateliers Rossolde;;;non;;
Selvault;Émilie;Fabrique Sorbagne;;;non;;
Neskumet;Léa;Verrerie Aubrenc;;;non;;
Korvichon;Isabelle;Menuiserie Ulvardes;;;non;;
Obrande;Michel;Menuiserie Estrolle;;;non;;
Luskeval;Isabelle;Brasserie Wassorel;;;non;;
Tolvoubre;Fatima;Papeterie Ulvardes;;;non;;
Gorzière;Zoé;Menuiserie Olbrecq;;;non;;
Korvignac;Sylvie;Fabrique Ulvardes;;;non;;
Obreval;Antoine;Ateliers Cassorgue;;;non;;
Korveron;Michel;Fonderie Sorbagne;;animation;non;;
Tolvumet;Camille;Fonderie Estrolle;;;non;;
Dulvesson;Marie;Ateliers Cassorgue;;;non;;
Jusvard;Youssef;Tannerie Aubrenc;;animation;non;;
Tolvignac;Guillaume;Menuiserie Sorbagne;;;non;;
Mirvotte;Gabriel;Tannerie Belvarre;;;non;;
Urvard;Marie;Filature Wassorel;;animation;non;;
Bralmesson;Marie;Coopérative Dravenne;;animation;non;;
Neskignac;Paul;Brasserie Sorbagne;;;non;;
Bralmeux;Samuel;Ateliers Rossolde;;animation;non;;
Ivrotte;Raphaël;Coopérative Rossolde;;;non;;
Luskouze;Marc;Ateliers Rossolde;;;non;;
Jusvotte;Sylvie;Brasserie Belvarre;;;non;;
Bralmoubre;Véronique;Fonderie Xandrelle;;;non;;
Selvard;Pierre;Verrerie Vaubrenne;;;non;;
Hulveux;Marie;Coopérative Rossolde;;;non;;
Pruvotte;Sophie;Imprimerie Yzorne;;animation;non;;
Fralveval;Karim;Coopérative Gravelune;;;non;;
Gorzignac;Philippe;Imprimerie Falvigne;;;non;;
Selvouze;Étienne;Ateliers Tavrenne;;;non;;
Hulvichon;Louis;Ateliers Falvigne;;;non;;
Urvoubre;Sylvie;Verrerie Vaubrenne;;;non;;
Pruveval;Pierre;Comptoir Xandrelle;;;non;;
Hulvotte;Guillaume;Verrerie Vaubrenne;;;non;;
Abrevignac;Geneviève;Brasserie Morvalle;;;non;;
Abrevault;Gabriel;Ateliers Rossolde;;animation;non;;
Bralmignac;Amina;Fonderie Estrolle;;;non;;
Bralmançon;Olivier;Comptoir Lavorneau;;;non;;
Luskoubre;Marc;Verrerie Vaubrenne;;;non;;
Abrevoubre;Geneviève;Menuiserie Gravelune;;;non;;
Korvoubre;Raphaël;Verrerie Vaubrenne;;;non;;
Mirvard;Pierre;Verrerie Ivoranche;;;non;;
Jusvumet;Camille;Fonderie Estrolle;;;non;;
Cloveval;Samuel;Menuiserie Ulvardes;;;non;;
Selvière;Antoine;Coopérative Dravenne;;;non;;
Jusvouze;Véronique;Verrerie Vaubrenne;;;non;;
Neskivet;Paul;Comptoir Jolvane;;;non;;
Dulvande;Samuel;Verrerie Pravette;;;non;;
Bralmault;Antoine;Fabrique Sorbagne;;animation;non;;
Abrevière;Émilie;Imprimerie Yzorne;;animation;non;;
Selveval;Lucie;Filature Zelvaine;;;non;;
Tolvichon;Olivier;Fonderie Xandrelle;;;non;;
Hulvard;Fatima;Ateliers Estrolle;;;non;;
Ivreux;Alexandre;Tannerie Aubrenc;;;non;;
Korveval;Félix;Tannerie Nervanche;;;non;;
Urvichon;Karim;Tannerie Nervanche;;;non;;
Dulvichon;Émilie;Menuiserie Ulvardes;;;non;;
Tolvouze;Luc;Fabrique Ivoranche;;;non;;
Jusvivet;Claire;Imprimerie Yzorne;;animation;non;;
Quilmault;Amina;Coopérative Jolvane;;;non;;
Ivrouze;François;Fonderie Aubrenc;;;non;;
Gorzumet;Hélène;Fonderie Aubrenc;;;non;;
Ivrault;Sophie;Coopérative Morvalle;;;non;;
Bralmière;Louise;Tannerie Quimvaux;;;non;;
Korvande;Nicolas;Comptoir Sorbagne;;;non;;
Ivrumet;Geneviève;Coopérative Jolvane;;;non;;
Obresson;Geneviève;Coopérative Gravelune;;;non;;
Obrançon;Guillaume;Papeterie Hostrenne;;;non;;
Urvignac;Fatima;Ateliers Aubrenc;;;non;;
Bralmotte;Claire;Ateliers Tavrenne;;;non;;
Hulvignac;Félix;Imprimerie Hostrenne;;;non;;
Clovotte;François;Coopérative Rossolde;;;non;;
Dulvoubre;Philippe;Brasserie Belvarre;;;non;;
Rozveron;Samuel;Fonderie Sorbagne;;;non;;
Hulvoubre;Pierre;Menuiserie Olbrecq;;;non;;
Urvouze;Émilie;Comptoir Gravelune;;;non;;
Korvançon;Félix;Fonderie Ivoranche;;;non;;
Neskande;Émilie;Coopérative Gravelune;;;non;;
Mirvumet;Chantal;Menuiserie Gravelune;;;non;;
Korvotte;Alexandre;Menuiserie Nervanche;;;non;;
Eskeval;Zoé;Tannerie Nervanche;;;non;;
Neskard;Camille;Menuiserie Gravelune;;animation;non;;
Tolveron;Manon;Brasserie Belvarre;;animation;non;;
Voskoubre;Nathalie;Fonderie Kalvenne;;;non;;
Pruvière;Sophie;Ateliers Rossolde;;;non;;
Obrichon;Paul;Ateliers Rossolde;;;non;;
Abrevançon;Fatima;Tannerie Nervanche;;animation;non;;
Eskière;Nathalie;Fonderie Xandrelle;;;non;;
Ivreron;Caroline;Imprimerie Falvigne;;;non;;
Neskoubre;Véronique;Fonderie Estrolle;;;non;;
Urvivet;Anne;Fonderie Kalvenne;;;non;;
Hulvançon;Olivier;Menuiserie Estrolle;;;non;;
Clovançon;Karim;Fabrique Sorbagne;;;non;;
Quilmotte;Zoé;Ateliers Aubrenc;;;non;;
Eskouze;Félix;Filature Zelvaine;;;non;;
Rozvichon;Amina;Verrerie Olbrecq;;;non;;
Fralvière;Simon;Comptoir Sorbagne;;;non;;
Vandergrolcquesmirtavelle-Ostrovaubelindigues;Marc;Tannerie Aubrenc;;animation;non;;
Hulvière;Pierre;Papeterie Hostrenne;;;non;;
Jusvault;Raphaël;Fabrique Sorbagne;;;non;;
Dulvivet;Guillaume;Filature Olbrecq;;animation;non;;
Fralvoubre;Pierre;Imprimerie Falvigne;;;non;;
Selveux;Hélène;Brasserie Lavorneau;;;non;;
Rozvançon;Caroline;Brasserie Belvarre;;;non;;
Korvière;Olivier;Ateliers Falvigne;;;non;;
Ivrichon;Nicolas;Fabrique Sorbagne;;;non;;
Korvumet;Félix;Imprimerie Yzorne;;;non;;
Voskande;Félix;Ateliers Falvigne;;animation;non;;
Selvumet;Guillaume;Papeterie Estrolle;;;non;;
Urvotte;Michel;Verrerie Ivoranche;;;non;;
Eskault;Émilie;Coopérative Jolvane;;;non;;
Bralmeval;Hélène;Brasserie Dravenne;;;non;;
Hulvesson;Guillaume;Comptoir Jolvane;;;non;;
Jusvoubre;Caroline;Fabrique Sorbagne;;;non;;
Luskeux;Félix;Tannerie Belvarre;;;non;;
Pruvouze;Antoine;Imprimerie Yzorne;;;non;;
Quilmignac;Julie;Menuiserie Jolvane;;;non;;
Tolvançon;Étienne;Menuiserie Estrolle;;animation;non;;
Voskumet;Hugo;Ateliers Tavrenne;;;non;;
Quilmumet;Manon;Fonderie Sorbagne;;;non;;
Voskouze;Marc;Fonderie Estrolle;;;non;;
Abrevard;Samuel;Filature Dravenne;;;non;;
Luskichon;Guillaume;Brasserie Belvarre;;animation;non;;
Clovesson;Manon;Fabrique Sorbagne;;;non;;
Hulvumet;Simon;Ateliers Ivoranche;;;non;;
Voskeron;Lucie;Verrerie Ivoranche;;;non;;
Selvignac;Thomas;Brasserie Sorbagne;;animation;non;;
Neskeron;Jean;Filature Zelvaine;;;non;;
Pruvivet;Manon;Menuiserie Aubrenc;;;non;;
Clovard;Sophie;Brasserie Belvarre;;;non;;
Gorzeval;Étienne;Papeterie Ulvardes;;;non;;
Ivroubre;Philippe;Ateliers Tavrenne;;animation;non;;
Luskault;Gabriel;Verrerie Gravelune;;;non;;
Korvard;Émilie;Verrerie Vaubrenne;;;non;;
Quilmichon;Thomas;Fonderie Estrolle;;;non;;
Eskançon;Alexandre;Menuiserie Hostrenne;;;non;;
Luskançon;Émilie;Ateliers Tavrenne;;;non;;
Dulvault;Marc;Verrerie Ivoranche;;;non;;
Rozveval;Hélène;Comptoir Ivoranche;;;non;;
Pruvançon;Julie;Imprimerie Yzorne;;;non;;
Mirveron;Louis;Filature Wassorel;;;non;;
Eskard;François;Fonderie Xandrelle;;;non;;
Bralmouze;Hugo;Tannerie Falvigne;;animation;non;;
Ivrançon;Louis;Coopérative Morvalle;;;non;;
Clovivet;Karim;Fonderie Kalvenne;;;non;;
Rozvande;Léa;Verrerie Olbrecq;;;non;;
Hulvouze;Amina;Coopérative Dravenne;;;non;;
Eskesson;Étienne;Menuiserie Ulvardes;;;non;;
Abreveval;Philippe;Ateliers Falvigne;;;non;;
Quilmeux;Émilie;Ateliers Falvigne;;;non;;
Ivreval;Gabriel;Verrerie Vaubrenne;;animation;non;;
Voskotte;Simon;Fabrique Ivoranche;;;non;;
Obrignac;Chantal;Imprimerie Yzorne;;;non;;
Eskotte;Guillaume;Verrerie Nervanche;;;non;;
Obrard;Léa;Comptoir Sorbagne;;;non;;
Gorzivet;Guillaume;Fonderie Estrolle;;;non;;
Mirvançon;Geneviève;Brasserie Belvarre;;;non;;
Selvotte;Julie;Comptoir Xandrelle;;;non;;
Mirvesson;Amina;Brasserie Sorbagne;;;non;;
Luskignac;Isabelle;Papeterie Estrolle;;;non;;
Obrumet;Chloé;Comptoir Yzorne;;;non;;
Abrevesson;Anne;Fonderie Estrolle;;;non;;
Rozvumet;Fatima;Menuiserie Hostrenne;;;non;;
Mirvoubre;Guillaume;Comptoir Sorbagne;;;non;;
Rozvoubre;Véronique;Fonderie Xandrelle;;;non;;
Gorzotte;Claire;Menuiserie Olbrecq;;;non;;
Ivrière;Antoine;Fabrique Sorbagne;;;non;;
Selvichon;Isabelle;Tannerie Nervanche;;;non;;
Urvault;Isabelle;Comptoir Yzorne;;;non;;
Voskesson;Julie;Brasserie Belvarre;;;non;;
Abrevotte;Chantal;Ateliers Ivoranche;;;non;;
Neskotte;Amina;Fabrique Kalvenne;;;non;;
Voskançon;Étienne;Menuiserie Olbrecq;;;non;;
Fralveron;Zoé;Menuiserie Sorbagne;;;non;;
Rozvouze;Marc;Ateliers Estrolle;;;non;;
Abreveux;Sylvie;Comptoir Jolvane;;;non;;
Pruvignac;Karim;Menuiserie Estrolle;;;non;;
Mirveval;Zoé;Fabrique Kalvenne;;;non;;
Fralvignac;Philippe;Menuiserie Estrolle;;;non;;
Gorzeux;Gabriel;Menuiserie Ulvardes;;;non;;
Urveval;Jean;Menuiserie Quimvaux;;;non;;

1 nom prenom appartenance courriel titre_pressenti exclu notes etiquettes
2 Mirvault Isabelle Comptoir Gravelune non
3 Neskière Youssef Verrerie Vaubrenne animation non
4 Abrevande Félix Imprimerie Yzorne non
5 Fralvançon Hugo Brasserie Wassorel non
6 Fralveux Manon Menuiserie Estrolle non
7 Luskotte Catherine Fonderie Estrolle non
8 WAMMERWOLM-MAWWEMMEL Zoé Menuiserie Ulvardes non
9 Neskichon Louise Ateliers Rossolde animation non
10 Urvesson Catherine Fabrique Ulvardes non
11 Hulvivet Karim Fonderie Xandrelle non
12 Selveron Caroline Ateliers Cassorgue non
13 Korvault Catherine Tannerie Nervanche non
14 Fralvotte Caroline Fonderie Ivoranche non
15 Jusvesson Geneviève Tannerie Belvarre non
16 Abrevumet Paul Ateliers Aubrenc non
17 Fralvault Léa Fonderie Sorbagne non
18 Obrivet Fatima Fabrique Sorbagne non
19 Voskeval Philippe Menuiserie Estrolle animation non
20 Dulveval Zoé Menuiserie Quimvaux non
21 Korveux Paul Filature Olbrecq non
22 Luskière Julie Fabrique Sorbagne non
23 Clovande Émilie Ateliers Falvigne non
24 Hulvande Paul Verrerie Vaubrenne non
25 Fralvesson Hugo Filature Wassorel non
26 Urvière Isabelle Brasserie Belvarre non
27 Rozvotte Thomas Verrerie Ivoranche non
28 Bralmumet Isabelle Verrerie Aubrenc non
29 Dulvouze Catherine Ateliers Rossolde non
30 Voskichon Marc Menuiserie Estrolle non
31 Mirvivet Simon Ateliers Ivoranche non
32 Clovault Louise Imprimerie Yzorne non
33 Voskeux Lucie Coopérative Morvalle non
34 Eskignac Amina Brasserie Xandrelle non
35 Korvouze Fatima Ateliers Tavrenne animation non
36 Selvoubre Luc Brasserie Sorbagne non
37 Fralvichon Étienne Papeterie Ulvardes non
38 Voskivet Philippe Ateliers Tavrenne non
39 Quilmançon Louise Menuiserie Estrolle non
40 Luskande Sophie Fabrique Morvalle non
41 Abreveron Pierre Ateliers Rossolde non
42 Neskeux Philippe Comptoir Xandrelle non
43 Neskançon Amina Fabrique Ulvardes non
44 Tolvande Léa Ateliers Rossolde non
45 Quilmeron Antoine Fabrique Kalvenne non
46 Clovumet Guillaume Verrerie Pravette non
47 Eskeux Michel Papeterie Wassorel non
48 Hulvault Louise Coopérative Jolvane non
49 Hulveron Catherine Coopérative Rossolde animation non
50 Ivrivet François Menuiserie Sorbagne animation non
51 Eskumet Samuel Verrerie Ivoranche non
52 Fralvivet Claire Imprimerie Yzorne non
53 Mirveux Léa Menuiserie Wassorel non
54 Gorzesson Karim Papeterie Ulvardes non
55 Fralvumet Jean Ateliers Falvigne animation non
56 Urvançon Manon Ateliers Tavrenne animation non
57 Quilmande Sylvie Imprimerie Yzorne non
58 Gorzichon François Ateliers Estrolle non
59 Pruvesson Amina Fonderie Sorbagne non
60 Quilmière Lucie Fabrique Ivoranche non
61 Korvesson Fatima Ateliers Rossolde non
62 Selvesson Nicolas Imprimerie Yzorne non
63 Selvivet Louis Menuiserie Hostrenne animation non
64 Neskesson Louise Tannerie Belvarre non
65 Tolvesson Lucie Filature Zelvaine non
66 Quilmesson Olivier Comptoir Gravelune non
67 Luskivet Guillaume Brasserie Lavorneau non
68 Neskault Gabriel Menuiserie Tavrenne non
69 Urvumet Antoine Menuiserie Hostrenne non
70 Jusvançon Olivier Brasserie Morvalle non
71 Dulvançon Simon Menuiserie Ivoranche non
72 Gorzard Chloé Fabrique Sorbagne non
73 Pruveux Gabriel Papeterie Estrolle non
74 Clovière Manon Ateliers Ivoranche non
75 Obrouze François Tannerie Olbrecq animation non
76 Tolveval Karim Tannerie Belvarre non
77 Dulvumet Geneviève Menuiserie Quimvaux animation non
78 Bérêçàuvïlle-Dœûmôntëÿ Hugo Ateliers Falvigne non
79 Gorzeron Claire Menuiserie Hostrenne non
80 Luskumet Hélène Brasserie Xandrelle non
81 Fralvande Louis Ateliers Rossolde non
82 Selvault Émilie Fabrique Sorbagne non
83 Neskumet Léa Verrerie Aubrenc non
84 Korvichon Isabelle Menuiserie Ulvardes non
85 Obrande Michel Menuiserie Estrolle non
86 Luskeval Isabelle Brasserie Wassorel non
87 Tolvoubre Fatima Papeterie Ulvardes non
88 Gorzière Zoé Menuiserie Olbrecq non
89 Korvignac Sylvie Fabrique Ulvardes non
90 Obreval Antoine Ateliers Cassorgue non
91 Korveron Michel Fonderie Sorbagne animation non
92 Tolvumet Camille Fonderie Estrolle non
93 Dulvesson Marie Ateliers Cassorgue non
94 Jusvard Youssef Tannerie Aubrenc animation non
95 Tolvignac Guillaume Menuiserie Sorbagne non
96 Mirvotte Gabriel Tannerie Belvarre non
97 Urvard Marie Filature Wassorel animation non
98 Bralmesson Marie Coopérative Dravenne animation non
99 Neskignac Paul Brasserie Sorbagne non
100 Bralmeux Samuel Ateliers Rossolde animation non
101 Ivrotte Raphaël Coopérative Rossolde non
102 Luskouze Marc Ateliers Rossolde non
103 Jusvotte Sylvie Brasserie Belvarre non
104 Bralmoubre Véronique Fonderie Xandrelle non
105 Selvard Pierre Verrerie Vaubrenne non
106 Hulveux Marie Coopérative Rossolde non
107 Pruvotte Sophie Imprimerie Yzorne animation non
108 Fralveval Karim Coopérative Gravelune non
109 Gorzignac Philippe Imprimerie Falvigne non
110 Selvouze Étienne Ateliers Tavrenne non
111 Hulvichon Louis Ateliers Falvigne non
112 Urvoubre Sylvie Verrerie Vaubrenne non
113 Pruveval Pierre Comptoir Xandrelle non
114 Hulvotte Guillaume Verrerie Vaubrenne non
115 Abrevignac Geneviève Brasserie Morvalle non
116 Abrevault Gabriel Ateliers Rossolde animation non
117 Bralmignac Amina Fonderie Estrolle non
118 Bralmançon Olivier Comptoir Lavorneau non
119 Luskoubre Marc Verrerie Vaubrenne non
120 Abrevoubre Geneviève Menuiserie Gravelune non
121 Korvoubre Raphaël Verrerie Vaubrenne non
122 Mirvard Pierre Verrerie Ivoranche non
123 Jusvumet Camille Fonderie Estrolle non
124 Cloveval Samuel Menuiserie Ulvardes non
125 Selvière Antoine Coopérative Dravenne non
126 Jusvouze Véronique Verrerie Vaubrenne non
127 Neskivet Paul Comptoir Jolvane non
128 Dulvande Samuel Verrerie Pravette non
129 Bralmault Antoine Fabrique Sorbagne animation non
130 Abrevière Émilie Imprimerie Yzorne animation non
131 Selveval Lucie Filature Zelvaine non
132 Tolvichon Olivier Fonderie Xandrelle non
133 Hulvard Fatima Ateliers Estrolle non
134 Ivreux Alexandre Tannerie Aubrenc non
135 Korveval Félix Tannerie Nervanche non
136 Urvichon Karim Tannerie Nervanche non
137 Dulvichon Émilie Menuiserie Ulvardes non
138 Tolvouze Luc Fabrique Ivoranche non
139 Jusvivet Claire Imprimerie Yzorne animation non
140 Quilmault Amina Coopérative Jolvane non
141 Ivrouze François Fonderie Aubrenc non
142 Gorzumet Hélène Fonderie Aubrenc non
143 Ivrault Sophie Coopérative Morvalle non
144 Bralmière Louise Tannerie Quimvaux non
145 Korvande Nicolas Comptoir Sorbagne non
146 Ivrumet Geneviève Coopérative Jolvane non
147 Obresson Geneviève Coopérative Gravelune non
148 Obrançon Guillaume Papeterie Hostrenne non
149 Urvignac Fatima Ateliers Aubrenc non
150 Bralmotte Claire Ateliers Tavrenne non
151 Hulvignac Félix Imprimerie Hostrenne non
152 Clovotte François Coopérative Rossolde non
153 Dulvoubre Philippe Brasserie Belvarre non
154 Rozveron Samuel Fonderie Sorbagne non
155 Hulvoubre Pierre Menuiserie Olbrecq non
156 Urvouze Émilie Comptoir Gravelune non
157 Korvançon Félix Fonderie Ivoranche non
158 Neskande Émilie Coopérative Gravelune non
159 Mirvumet Chantal Menuiserie Gravelune non
160 Korvotte Alexandre Menuiserie Nervanche non
161 Eskeval Zoé Tannerie Nervanche non
162 Neskard Camille Menuiserie Gravelune animation non
163 Tolveron Manon Brasserie Belvarre animation non
164 Voskoubre Nathalie Fonderie Kalvenne non
165 Pruvière Sophie Ateliers Rossolde non
166 Obrichon Paul Ateliers Rossolde non
167 Abrevançon Fatima Tannerie Nervanche animation non
168 Eskière Nathalie Fonderie Xandrelle non
169 Ivreron Caroline Imprimerie Falvigne non
170 Neskoubre Véronique Fonderie Estrolle non
171 Urvivet Anne Fonderie Kalvenne non
172 Hulvançon Olivier Menuiserie Estrolle non
173 Clovançon Karim Fabrique Sorbagne non
174 Quilmotte Zoé Ateliers Aubrenc non
175 Eskouze Félix Filature Zelvaine non
176 Rozvichon Amina Verrerie Olbrecq non
177 Fralvière Simon Comptoir Sorbagne non
178 Vandergrolcquesmirtavelle-Ostrovaubelindigues Marc Tannerie Aubrenc animation non
179 Hulvière Pierre Papeterie Hostrenne non
180 Jusvault Raphaël Fabrique Sorbagne non
181 Dulvivet Guillaume Filature Olbrecq animation non
182 Fralvoubre Pierre Imprimerie Falvigne non
183 Selveux Hélène Brasserie Lavorneau non
184 Rozvançon Caroline Brasserie Belvarre non
185 Korvière Olivier Ateliers Falvigne non
186 Ivrichon Nicolas Fabrique Sorbagne non
187 Korvumet Félix Imprimerie Yzorne non
188 Voskande Félix Ateliers Falvigne animation non
189 Selvumet Guillaume Papeterie Estrolle non
190 Urvotte Michel Verrerie Ivoranche non
191 Eskault Émilie Coopérative Jolvane non
192 Bralmeval Hélène Brasserie Dravenne non
193 Hulvesson Guillaume Comptoir Jolvane non
194 Jusvoubre Caroline Fabrique Sorbagne non
195 Luskeux Félix Tannerie Belvarre non
196 Pruvouze Antoine Imprimerie Yzorne non
197 Quilmignac Julie Menuiserie Jolvane non
198 Tolvançon Étienne Menuiserie Estrolle animation non
199 Voskumet Hugo Ateliers Tavrenne non
200 Quilmumet Manon Fonderie Sorbagne non
201 Voskouze Marc Fonderie Estrolle non
202 Abrevard Samuel Filature Dravenne non
203 Luskichon Guillaume Brasserie Belvarre animation non
204 Clovesson Manon Fabrique Sorbagne non
205 Hulvumet Simon Ateliers Ivoranche non
206 Voskeron Lucie Verrerie Ivoranche non
207 Selvignac Thomas Brasserie Sorbagne animation non
208 Neskeron Jean Filature Zelvaine non
209 Pruvivet Manon Menuiserie Aubrenc non
210 Clovard Sophie Brasserie Belvarre non
211 Gorzeval Étienne Papeterie Ulvardes non
212 Ivroubre Philippe Ateliers Tavrenne animation non
213 Luskault Gabriel Verrerie Gravelune non
214 Korvard Émilie Verrerie Vaubrenne non
215 Quilmichon Thomas Fonderie Estrolle non
216 Eskançon Alexandre Menuiserie Hostrenne non
217 Luskançon Émilie Ateliers Tavrenne non
218 Dulvault Marc Verrerie Ivoranche non
219 Rozveval Hélène Comptoir Ivoranche non
220 Pruvançon Julie Imprimerie Yzorne non
221 Mirveron Louis Filature Wassorel non
222 Eskard François Fonderie Xandrelle non
223 Bralmouze Hugo Tannerie Falvigne animation non
224 Ivrançon Louis Coopérative Morvalle non
225 Clovivet Karim Fonderie Kalvenne non
226 Rozvande Léa Verrerie Olbrecq non
227 Hulvouze Amina Coopérative Dravenne non
228 Eskesson Étienne Menuiserie Ulvardes non
229 Abreveval Philippe Ateliers Falvigne non
230 Quilmeux Émilie Ateliers Falvigne non
231 Ivreval Gabriel Verrerie Vaubrenne animation non
232 Voskotte Simon Fabrique Ivoranche non
233 Obrignac Chantal Imprimerie Yzorne non
234 Eskotte Guillaume Verrerie Nervanche non
235 Obrard Léa Comptoir Sorbagne non
236 Gorzivet Guillaume Fonderie Estrolle non
237 Mirvançon Geneviève Brasserie Belvarre non
238 Selvotte Julie Comptoir Xandrelle non
239 Mirvesson Amina Brasserie Sorbagne non
240 Luskignac Isabelle Papeterie Estrolle non
241 Obrumet Chloé Comptoir Yzorne non
242 Abrevesson Anne Fonderie Estrolle non
243 Rozvumet Fatima Menuiserie Hostrenne non
244 Mirvoubre Guillaume Comptoir Sorbagne non
245 Rozvoubre Véronique Fonderie Xandrelle non
246 Gorzotte Claire Menuiserie Olbrecq non
247 Ivrière Antoine Fabrique Sorbagne non
248 Selvichon Isabelle Tannerie Nervanche non
249 Urvault Isabelle Comptoir Yzorne non
250 Voskesson Julie Brasserie Belvarre non
251 Abrevotte Chantal Ateliers Ivoranche non
252 Neskotte Amina Fabrique Kalvenne non
253 Voskançon Étienne Menuiserie Olbrecq non
254 Fralveron Zoé Menuiserie Sorbagne non
255 Rozvouze Marc Ateliers Estrolle non
256 Abreveux Sylvie Comptoir Jolvane non
257 Pruvignac Karim Menuiserie Estrolle non
258 Mirveval Zoé Fabrique Kalvenne non
259 Fralvignac Philippe Menuiserie Estrolle non
260 Gorzeux Gabriel Menuiserie Ulvardes non
261 Urveval Jean Menuiserie Quimvaux non

View file

@ -1,13 +1,13 @@
nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes
Bralmumet;Caroline;Ateliers Jolvane;;;non;
Neskault;Hugo;Tannerie Cassorgue;;;non;
Ivrotte;Youssef;Verrerie Ulvardes;;;non;
Selvière;Louis;Tannerie Cassorgue;;;non;
Selvançon;Youssef;Ateliers Jolvane;;;non;
Quilmoubre;Amina;Verrerie Ulvardes;;;non;
Voskotte;Julie;Verrerie Ulvardes;;;non;
Neskivet;Paul;Ateliers Jolvane;;;non;
Bralmignac;Michel;Tannerie Cassorgue;;;non;
Tolveval;Zoé;Verrerie Ulvardes;;;non;
Urvard;Luc;Tannerie Cassorgue;;;non;
Mirveval;Véronique;Ateliers Jolvane;;;non;
nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes;etiquettes
Bralmumet;Caroline;Ateliers Jolvane;;;non;;
Neskault;Hugo;Tannerie Cassorgue;;;non;;
Ivrotte;Youssef;Verrerie Ulvardes;;;non;;
Selvière;Louis;Tannerie Cassorgue;;;non;;
Selvançon;Youssef;Ateliers Jolvane;;;non;;
Quilmoubre;Amina;Verrerie Ulvardes;;;non;;
Voskotte;Julie;Verrerie Ulvardes;;;non;;
Neskivet;Paul;Ateliers Jolvane;;;non;;
Bralmignac;Michel;Tannerie Cassorgue;;;non;;
Tolveval;Zoé;Verrerie Ulvardes;;;non;;
Urvard;Luc;Tannerie Cassorgue;;;non;;
Mirveval;Véronique;Ateliers Jolvane;;;non;;

1 nom prenom appartenance courriel titre_pressenti exclu notes etiquettes
2 Bralmumet Caroline Ateliers Jolvane non
3 Neskault Hugo Tannerie Cassorgue non
4 Ivrotte Youssef Verrerie Ulvardes non
5 Selvière Louis Tannerie Cassorgue non
6 Selvançon Youssef Ateliers Jolvane non
7 Quilmoubre Amina Verrerie Ulvardes non
8 Voskotte Julie Verrerie Ulvardes non
9 Neskivet Paul Ateliers Jolvane non
10 Bralmignac Michel Tannerie Cassorgue non
11 Tolveval Zoé Verrerie Ulvardes non
12 Urvard Luc Tannerie Cassorgue non
13 Mirveval Véronique Ateliers Jolvane non

View file

@ -32,11 +32,12 @@
// la sortie ne porte que des durées.
import { beforeAll, describe, expect, test } from 'vitest';
import { commands } from 'vitest/browser';
import { configurationDe } from '../../src/application/configuration.js';
import { creerExecuteurTravailleur } from '../../src/application/executeur_travailleur.js';
import { coeursDe, ralentissement } from '../../src/application/generation.js';
import { texteLivre } from '../../src/demo/livrees.js';
import Travailleur from '../../src/interface/generation/travailleur.js?worker&inline';
import { GENERATION_PAR_DEFAUT, analyser, configurationDepuisCharge } from '../../src/stockage/document.js';
import { GENERATION_PAR_DEFAUT, analyser } from '../../src/stockage/document.js';
import { controlerCadre, ecrireSortie, ralentirCpu } from './navigateur.js';
import { verifierReleve } from './releve.js';
@ -47,7 +48,8 @@ const GRAINE = 1;
// Les rondes de mesure, et la ronde de plus d'une mesure bruitée.
const REPETITIONS = 3;
const CONFIGURATION = configurationDepuisCharge(analyser(texteLivre('grande')).charge);
// La grande démonstration, comme la génération la lit : animateurs compris.
const CONFIGURATION = configurationDe(analyser(texteLivre('grande')).charge);
// Les processeurs logiques du poste, tels que l'exécuteur de production les lit.
const L = coeursDe(navigator.hardwareConcurrency);

View file

@ -16,11 +16,12 @@
import { realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { configurationPessimiste } from '../../test/fixtures/banc/pessimiste.js';
import { configurationDe } from '../../src/application/configuration.js';
import { LIVREES } from '../../src/demo/livrees.js';
import { normaliser } from '../../src/moteur/configuration.js';
import { diagnostiquer } from '../../src/moteur/diagnostic.js';
import { plafondsAPriori } from '../../src/moteur/plafond.js';
import { analyser, configurationDepuisCharge } from '../../src/stockage/document.js';
import { analyser } from '../../src/stockage/document.js';
// Les démonstrations livrées que le § 19.10 nomme : la grande et sa variante.
const DEMONSTRATIONS_MESUREES = ['grande', 'grande-sans-exception'];
@ -116,7 +117,8 @@ export function mesurerPlafondAPriori({ configurations, maintenant, repetitions
/**
* Les configurations du banc, dans l'ordre du relevé : la grande
* démonstration et sa variante, lues dans leur fichier livré (src/demo/
* livrees.js) comme l'opérateur les ouvre, puis la configuration pessimiste
* livrees.js) comme l'opérateur les ouvre, par configurationDe, animateurs
* compris, puis la configuration pessimiste
* (test/fixtures/banc/pessimiste.js). Des objets neufs à chaque appel.
*
* @returns {Array<{cle: string, configuration: import('../../src/moteur/types.js').Configuration}>}
@ -124,7 +126,7 @@ export function mesurerPlafondAPriori({ configurations, maintenant, repetitions
export function configurationsDuBanc() {
const livrees = DEMONSTRATIONS_MESUREES.map((cle) => ({
cle,
configuration: configurationDepuisCharge(analyser(LIVREES.find((livree) => livree.cle === cle).texte).charge),
configuration: configurationDe(analyser(LIVREES.find((livree) => livree.cle === cle).texte).charge),
}));
return [...livrees, { cle: 'pessimiste', configuration: configurationPessimiste() }];
}

View file

@ -63,6 +63,7 @@ import { readFileSync, realpathSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { COMMANDES } from '../src/application/commandes.js';
import { configurationDe } from '../src/application/configuration.js';
import { creerFormat } from '../src/application/format.js';
import { TABLES_APPLICATION } from '../src/application/libelles.js';
import { creerTraduction } from '../src/application/traduction.js';
@ -74,7 +75,7 @@ import { REGLAGES_GENERATION } from '../src/interface/generation/reglages.js';
import { TABLES_INTERFACE } from '../src/interface/libelles.js';
import { RACCOURCIS } from '../src/interface/raccourcis.js';
import { diagnostiquer } from '../src/moteur/diagnostic.js';
import { analyser, configurationDepuisCharge } from '../src/stockage/document.js';
import { analyser } from '../src/stockage/document.js';
import { VERSION } from '../src/version.genere.js';
import { CHEMIN_MANIFESTE, cheminImage, ecartDeVersion, fautesDImage } from './documentation/manifeste.js';
import { tableauConstantes } from './banc/releve.js';
@ -362,6 +363,10 @@ export const EXEMPLES_COMMANDES = Object.freeze({
changerTours: { tours: 3, retirees: 2 },
modifierReglage: { cle: 'separerAppartenances', valeur: false },
changerCibleRedondance: { cible: 1200 },
changerEtiquettes: { personne: 'Iris Ombrelle', ajoutees: ['finance'], retirees: [] },
poserObjectif: { numero: 3, tour: null, regle: null, etiquettes: ['finance'] },
retirerObjectif: { numero: 3, tour: 2 },
changerRegleObjectifs: { regle: 'et' },
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 },
@ -475,7 +480,7 @@ export function tableauDemonstrations(livrees = LIVREES) {
livrees.map(({ cle, texte }) => {
const entree = CATALOGUE.find((candidate) => candidate.cle === cle);
if (entree === undefined) throw new RangeError(`demonstrations : « ${cle} » n'est pas au catalogue`);
const configuration = configurationDepuisCharge(analyser(texte).charge);
const configuration = configurationDe(analyser(texte).charge);
const { plafondAPriori } = diagnostiquer(configuration);
return [
entree.nom,

View file

@ -15,8 +15,9 @@
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { LIBRE, nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
import { verifierInvariants } from '../moteur/verification.js';
import { capacite, configurationDepuisCharge } from '../stockage/document.js';
import { capacite } from '../stockage/document.js';
import { examiner, planDepuisFichier } from '../stockage/placements.js';
import { configurationDe } from './configuration.js';
import { nomAffiche } from './personnes.js';
import { couvre, placesTenues, presents, reservationsActives } from './places.js';
@ -26,7 +27,7 @@ const avertissement = (code, details) => ({ code, details });
// { instance } ou { refus }. Une autre erreur remonte.
function instanceDe(charge) {
try {
return { instance: normaliser(configurationDepuisCharge(charge)), refus: null };
return { instance: normaliser(configurationDe(charge)), refus: null };
} catch (erreur) {
if (!(erreur instanceof ErreurConfiguration)) throw erreur;
return { instance: null, refus: erreur };
@ -230,7 +231,7 @@ export function invariantViole(charge, examen = examiner(charge)) {
* lisent sur examiner(avant) et examiner(apres) (S2), et planDepuisFichier
* ne reçoit le retenu que quand examiner(…).retenu.fautes est vide.
*
* CONFIGURATION_INCOHERENTE {code, details} normaliser(configurationDepuisCharge(apres))
* CONFIGURATION_INCOHERENTE {code, details} normaliser(configurationDe(apres))
* lève, et celle d'avant se normalisait ; PLACES_MANQUANTES et
* RESERVATION_NON_HONOREE, qui lisent l'instance d'après, ne se calculent
* alors pas ;

View file

@ -10,10 +10,11 @@
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
import { configurationDepuisCharge, creerCharge } from '../stockage/document.js';
import { creerCharge } from '../stockage/document.js';
import { examiner } from '../stockage/placements.js';
import { VERSION } from '../version.genere.js';
import { avertissementsApres, examenNomme, invariantViole } from './avertissements.js';
import { configurationDe } from './configuration.js';
const personne = (id, nom, exclu = false) => ({
id,
@ -50,7 +51,7 @@ function sallePleine() {
return charge;
}
const manqueDu = (charge) => nombrePlacesManquantes(normaliser(configurationDepuisCharge(charge)));
const manqueDu = (charge) => nombrePlacesManquantes(normaliser(configurationDe(charge)));
// Quatre personnes, deux tables de deux, deux tours ; Iris Ombrelle réservée
// à la table 1 pour tous les tours. Le placement de la proposition 1 : Iris

View file

@ -1,16 +1,18 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les bilans du mobilier (§ 6.1, § 6.2, § 2.1 étape 5) : ce qu'un changement
// de défaut, de tours ou la suppression d'une table emporterait, compté sur
// la charge courante sans rien écrire, pour l'énoncer avant d'appliquer. La
// commande qui applique lit le même bilan : changerDefaut lève le premier de
// ses refus. Les refus et avertissements d'une capacité sont ceux de
// controlerCapacites (capacite.js), le seul contrôle. Aucune fonction ne
// modifie ce qu'elle reçoit.
// Les bilans du mobilier (§ 6.1, § 6.2, § 2.1 étape 5, § 4.5) : ce qu'un
// changement de défaut, de tours ou la suppression d'une table emporterait,
// objectifs des tables compris, compté sur la charge courante sans rien
// écrire, pour l'énoncer avant d'appliquer. La commande qui applique lit le
// même bilan : changerDefaut lève le premier de ses refus. Les refus et
// avertissements d'une capacité sont ceux de controlerCapacites
// (capacite.js), le seul contrôle ; l'ordre des objectifs, ordreDesObjectifs
// (objectifs.js). Aucune fonction ne modifie ce qu'elle reçoit.
import { ordreDesReservations } from '../csv/import.js';
import { SIEGES_MAX, capacite } from '../stockage/document.js';
import { controlerCapacites } from './capacite.js';
import { ordreDesObjectifs } from './objectifs.js';
import { presents } from './places.js';
const croissant = (a, b) => a - b;
@ -88,9 +90,11 @@ export function bilanChangementDefaut(charge, sieges, { realigner = false } = {}
}
/**
* Le changement du nombre de tours à tours (§ 2.1 étape 5, § 4.3) :
* Le changement du nombre de tours à tours (§ 2.1 étape 5, § 4.3, § 4.5) :
* reservationsDetruites, les réservations « tour désigné » au-delà, dans
* l'ordre canonique ; propositionsTouchees, les identifiants croissants des
* l'ordre canonique ; objectifsDetruits, les objectifs de table des tours
* au-delà, par identifiant de table puis tour croissants — ceux de la
* soirée restent ; propositionsTouchees, les identifiants croissants des
* propositions qui ne porteront pas tours tours, et retenuTouche de même
* pour le retenu — ils restent, en dérive TOURS_CHANGES (§ 9). Le nombre de
* tours d'aujourd'hui ne touche rien. La portée « tous » ne se détruit
@ -99,7 +103,8 @@ export function bilanChangementDefaut(charge, sieges, { realigner = false } = {}
* @param {import('../stockage/types.js').Charge} charge
* @param {number} tours
* @returns {{reservationsDetruites: Array<{participant: number, table: number, tour: number}>,
* propositionsTouchees: number[], retenuTouche: boolean}}
* objectifsDetruits: Array<{table: number, tour: number}>, propositionsTouchees: number[],
* retenuTouche: boolean}}
* @throws {TypeError} tours qui n'est pas un entier
*/
export function bilanTours(charge, tours) {
@ -111,26 +116,38 @@ export function bilanTours(charge, tours) {
.filter(({ portee, tour }) => portee === 'tour' && tour > tours)
.sort(ordreDesReservations)
.map(({ participant, table, tour }) => ({ participant, table, tour })),
objectifsDetruits: [...charge.tables]
.sort((a, b) => a.id - b.id)
.flatMap(({ id, objectifs = [] }) =>
objectifs
.filter(({ portee, tour }) => portee === 'tour' && tour > tours)
.map(({ tour }) => ({ table: id, tour }))
.sort((a, b) => a.tour - b.tour),
),
propositionsTouchees: identifiants(charge.propositions, touche),
retenuTouche: charge.retenu !== null && touche(charge.retenu),
};
}
/**
* La suppression de la table id (§ 6.2, § 4) : ses réservations, suspendues
* comprises, dans l'ordre canonique, et ses titres par siège puis libellé,
* qui partent avec elle ; propositionsTouchees, les identifiants croissants
* des propositions qui la déclarent, et retenuTouche de même — ils restent,
* en dérive TABLE_SUPPRIMEE (§ 9). Une table nue rend deux listes vides.
* La suppression de la table id (§ 6.2, § 4, § 4.5) : ses réservations,
* suspendues comprises, dans l'ordre canonique, ses titres par siège puis
* libellé, et ses objectifs {tour, regle, etiquettes}, la soirée d'abord
* puis les tours croissants, qui partent avec elle ; propositionsTouchees,
* les identifiants croissants des propositions qui la déclarent, et
* retenuTouche de même — ils restent, en dérive TABLE_SUPPRIMEE (§ 9). Une
* table nue rend trois listes vides.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {number} id
* @returns {{reservations: Array<{participant: number, portee: 'tous'|'tour', tour: number|null, siege: number|null}>,
* titres: Array<{siege: number, libelle: string}>, propositionsTouchees: number[], retenuTouche: boolean}}
* titres: Array<{siege: number, libelle: string}>,
* objectifs: Array<{tour: number|null, regle: 'ou'|'et'|null, etiquettes: string[]}>,
* propositionsTouchees: number[], retenuTouche: boolean}}
* @throws {TypeError} une table inconnue
*/
export function bilanSuppressionTable(charge, id) {
tableDe(charge, id);
const table = tableDe(charge, id);
const declare = (placement) => Array.isArray(placement?.tables) && placement.tables.includes(id);
return {
reservations: charge.reservations
@ -141,6 +158,9 @@ export function bilanSuppressionTable(charge, id) {
.filter(({ table }) => table === id)
.sort((a, b) => a.siege - b.siege || comparerTextes(a.libelle, b.libelle))
.map(({ siege, libelle }) => ({ siege, libelle })),
objectifs: [...(table.objectifs ?? [])]
.sort(ordreDesObjectifs)
.map(({ tour, regle, etiquettes }) => ({ tour, regle, etiquettes: [...etiquettes] })),
propositionsTouchees: identifiants(charge.propositions, declare),
retenuTouche: charge.retenu !== null && declare(charge.retenu),
};

View file

@ -178,6 +178,7 @@ describe('bilans : changer les tours (§ 2.1 étape 5, § 4.3)', () => {
{ participant: 1, table: 1, tour: 4 },
{ participant: 2, table: 2, tour: 4 },
],
objectifsDetruits: [],
propositionsTouchees: [1, 2],
retenuTouche: true,
});
@ -185,8 +186,25 @@ describe('bilans : changer les tours (§ 2.1 étape 5, § 4.3)', () => {
test("le même nombre ne touche rien ; en ajouter ne détruit rien, mais périme les placements", () => {
const charge = quatreTours();
assert.deepEqual(bilanTours(charge, 4), { reservationsDetruites: [], propositionsTouchees: [], retenuTouche: false });
assert.deepEqual(bilanTours(charge, 5), { reservationsDetruites: [], propositionsTouchees: [1, 2], retenuTouche: true });
const rien = { reservationsDetruites: [], objectifsDetruits: [] };
assert.deepEqual(bilanTours(charge, 4), { ...rien, propositionsTouchees: [], retenuTouche: false });
assert.deepEqual(bilanTours(charge, 5), { ...rien, propositionsTouchees: [1, 2], retenuTouche: true });
});
test('retirer les tours 3 et 4 compte les objectifs de ces tours, par table puis tour, quel que soit l’ordre de la charge ; la soirée reste', () => {
const charge = quatreTours();
const objectif = (tour) => ({ portee: tour === null ? 'tous' : 'tour', tour, regle: null, etiquettes: ['finance'] });
charge.tables[1].objectifs = [objectif(null), objectif(2), objectif(3), objectif(4)];
charge.tables[0].objectifs = [objectif(4)];
charge.tables.reverse();
assert.deepEqual(bilanTours(charge, 2).objectifsDetruits, [
{ table: 1, tour: 4 },
{ table: 2, tour: 3 },
{ table: 2, tour: 4 },
]);
assert.deepEqual(bilanTours(charge, 4).objectifsDetruits, []);
// La commande détruit ce que le bilan annonce.
assert.equal(COMMANDES.get('changerTours')(charge, { tours: 2 }).libelle, 'Nombre de tours : 2 ; 2 réservations retirées, 3 objectifs retirés');
});
});
@ -208,19 +226,38 @@ describe("bilans : supprimer une table (§ 6.2, § 4)", () => {
assert.deepEqual(bilanSuppressionTable(garnie(), 2), {
reservations: [{ participant: 2, portee: 'tous', tour: null, siege: 2 }],
titres: [{ siege: 2, libelle: 'animateur' }],
objectifs: [],
propositionsTouchees: [1, 2],
retenuTouche: true,
});
});
test("les objectifs de la table partent avec elle : le bilan les nomme, la soirée d'abord, puis les tours", () => {
const charge = garnie();
charge.tables[1].objectifs = [
{ portee: 'tour', tour: 2, regle: 'et', etiquettes: ['Montréal', 'finance'] },
{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] },
];
assert.deepEqual(bilanSuppressionTable(charge, 2).objectifs, [
{ tour: null, regle: null, etiquettes: ['finance'] },
{ tour: 2, regle: 'et', etiquettes: ['Montréal', 'finance'] },
]);
});
test('une table nue : deux listes vides ; une table créée après les placements ne les touche pas', () => {
const charge = garnie();
const nue = bilanSuppressionTable(charge, 3);
assert.deepEqual([nue.reservations, nue.titres], [[], []]);
assert.deepEqual([nue.reservations, nue.titres, nue.objectifs], [[], [], []]);
const creee = COMMANDES.get('creerTable')(charge, { forme: 'carree' }).charge;
const idCree = creee.tables.at(-1).id;
// creerTable fait entrer la table au retenu ; aucune proposition ne la déclare.
assert.deepEqual(bilanSuppressionTable(creee, idCree), { reservations: [], titres: [], propositionsTouchees: [], retenuTouche: true });
assert.deepEqual(bilanSuppressionTable(creee, idCree), {
reservations: [],
titres: [],
objectifs: [],
propositionsTouchees: [],
retenuTouche: true,
});
});
test('les titres se rangent par siège puis libellé', () => {

View file

@ -30,12 +30,14 @@ import { appliquerImport, bilanRemplacement } from '../csv/import.js';
import { serialiserCharge } from '../stockage/canonique.js';
import { SCHEMA, clesRangees, creerCharge, etatDeduit, premiereFaute } from '../stockage/document.js';
import { ErreurStockage } from '../stockage/erreurs.js';
import { ETIQUETTES_MAX } from '../stockage/etiquettes.js';
import { estHorodatage } from '../stockage/journal.js';
import { fautes, formeNommee, versFichier } from '../stockage/placements.js';
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 { nomAffiche } from './personnes.js';
import { couvre, repartirSieges, reservationsActives } from './places.js';
import { controlerReintegration, leverRefus } from './pose.js';
@ -188,6 +190,62 @@ function modifierParticipant(charge, { id, champs }) {
};
}
// Une chaîne, ou undefined pour un argument absent ; une autre valeur est une
// faute du code.
function texteOuAbsent(valeur, nom) {
if (valeur !== undefined && typeof valeur !== 'string') {
throw new TypeError(`${nom} : chaîne attendue, reçu ${JSON.stringify(valeur)}`);
}
return valeur;
}
/**
* changerEtiquettes {id, ajouter?, retirer?} (§ 4.5, § 5.9) : retirer, une
* étiquette que la personne porte, s'ôte d'abord ; puis ajouter, une saisie
* de une ou plusieurs étiquettes, se reçoit (recevoirEtiquettes) sur les
* étiquettes de l'événement entier, personnes et objectifs, telles que le
* retrait les laisse : une personne seule à porter une orthographe la
* change ainsi en un geste. L'effet se juge par comparaison d'ensembles :
* une liste qui garde les mêmes étiquettes est SANS_EFFET, quel que soit
* l'ordre de la saisie. La liste s'écrit triée par unités de code, et le
* champ se retire quand elle se vide. Refus : ETIQUETTE_INVALIDE {valeur,
* raison} pour un morceau fautif, ou pour une saisie qui ne donne aucune
* étiquette (raison 'vide') ; ETIQUETTES_EN_TROP {nombre} au-delà
* d'ETIQUETTES_MAX ; remède null : la saisie se corrige dans le champ même.
* Avertissements : ORTHOGRAPHE_RETENUE {saisie, retenue}. Ni ajouter ni
* retirer, ou l'un d'eux qui n'est pas une chaîne, est une faute du code.
*/
function changerEtiquettes(charge, { id, ajouter, retirer } = {}) {
exigerModifiable(charge);
texteOuAbsent(ajouter, 'ajouter');
texteOuAbsent(retirer, 'retirer');
if (ajouter === undefined && retirer === undefined) throw new TypeError('changerEtiquettes : ajouter ou retirer attendu');
const participant = participantDe(charge, id);
const avant = participant.etiquettes ?? [];
const gardees = avant.filter((motCle) => motCle !== retirer);
let recues = { etiquettes: [], avertissements: [] };
if (ajouter !== undefined) {
const restante = avecParticipant(charge, id, (copie) => ({ ...copie, etiquettes: gardees }));
recues = recevoirEtiquettes(etiquettesDeLEvenement(restante), ajouter);
if (recues.etiquettes.length === 0) throw refus('ETIQUETTE_INVALIDE', { valeur: '', raison: 'vide' });
}
const apres = etiquettesRangees(new Set([...gardees, ...recues.etiquettes]));
const ajoutees = apres.filter((motCle) => !avant.includes(motCle));
const retirees = avant.filter((motCle) => !apres.includes(motCle));
if (ajoutees.length === 0 && retirees.length === 0) throw refus('SANS_EFFET', { commande: 'changerEtiquettes' });
if (apres.length > ETIQUETTES_MAX) throw refus('ETIQUETTES_EN_TROP', { nombre: apres.length });
const suivante = avecParticipant(charge, id, (copie) => {
const reste = { ...copie };
delete reste.etiquettes;
return apres.length === 0 ? reste : { ...reste, etiquettes: apres };
});
return {
charge: suivante,
libelle: libelle('ENTREE_CHANGER_ETIQUETTES', { personne: nomAffiche(participant), ajoutees, retirees }),
avertissements: recues.avertissements,
};
}
// Les comptes d'une exclusion ou d'une réintégration (§ 4.4) : les
// réservations de la personne, que l'exclusion suspend sans les retirer, et
// les titres que le geste cesse de pourvoir, ou pourvoit de nouveau.
@ -486,6 +544,7 @@ export const COMMANDES = reunirCommandes(
new Map([
['ajouterParticipant', ajouterParticipant],
['modifierParticipant', modifierParticipant],
['changerEtiquettes', changerEtiquettes],
['exclureParticipant', exclureParticipant],
['reintegrerParticipant', reintegrerParticipant],
['supprimerParticipant', supprimerParticipant],

View file

@ -11,6 +11,7 @@ import { describe, test } from '../../test/lanceur.js';
import { apercevoir } from '../csv/apercu.js';
import { bilanRemplacement } from '../csv/import.js';
import { rechercher } from '../moteur/recherche.js';
import { serialiserCharge } from '../stockage/canonique.js';
import { configurationDepuisCharge, creerCharge, etatDeduit } from '../stockage/document.js';
import { derive, fautes, formeNommee, planDepuisFichier } from '../stockage/placements.js';
import { VERSION } from '../version.genere.js';
@ -96,10 +97,11 @@ const executer = (nom, charge, args) => COMMANDES.get(nom)(charge, args);
const CSV_DEUX = 'nom;prenom;appartenance\nAncolie;Bastien;Club des Merles\nBruyère;Capucine;\n';
describe('commandes : le registre (§ 8.2)', () => {
test("le registre porte les onze commandes d'A1, les dix des places et celles du mobilier, chacune une fonction", () => {
test("le registre porte les onze commandes d'A1, changerEtiquettes, les dix des places et celles du mobilier, chacune une fonction", () => {
const attendues = [
'ajouterParticipant',
'changerEtat',
'changerEtiquettes',
'convertirEnTous',
'debloquer',
'deplacerDansRetenu',
@ -120,7 +122,7 @@ describe('commandes : le registre (§ 8.2)', () => {
'retirerTitre',
'supprimerParticipant',
];
assert.equal(attendues.length, 21);
assert.equal(attendues.length, 22);
assert.deepEqual([...COMMANDES.keys()].sort(), [...attendues, ...COMMANDES_MOBILIER.keys()].sort());
for (const commande of COMMANDES.values()) assert.equal(typeof commande, 'function');
});
@ -257,6 +259,134 @@ describe('commandes : les participants (§ 4, § 4.4, § 10.2)', () => {
});
});
describe('commandes : les étiquettes d’une personne (§ 4.5, § 5.9, A12)', () => {
// La charge d'essai, Iris Ombrelle « finance », la table 2 d'objectif de
// soirée « ressources humaines » sous sa règle propre.
function chargeEtiquetee() {
const charge = chargeEssai();
charge.participants[0].etiquettes = ['finance'];
charge.tables[1].objectifs = [{ portee: 'tous', tour: null, regle: 'ou', etiquettes: ['ressources humaines'] }];
return charge;
}
const etiquettesDe = (charge, id) => charge.participants.find((p) => p.id === id).etiquettes;
test('ajouter une saisie de deux étiquettes : triées par unités de code, le libellé nomme la personne et chacune', () => {
const rendu = executer('changerEtiquettes', figer(chargeEssai()), { id: 3, ajouter: ' voile ; Montre\u{0301}al ' });
assert.deepEqual(etiquettesDe(rendu.charge, 3), ['Montréal', 'voile']);
assert.equal(rendu.libelle, 'Étiquettes de Théo Pervenche : ajouté « Montréal » et « voile »');
assert.deepEqual(rendu.avertissements, []);
const ordre = Object.keys(rendu.charge.participants[2]);
assert.equal(ordre.at(-1), 'etiquettes');
});
test('« a|b » saisi sur la fiche fait deux étiquettes : la barre sépare', () => {
assert.deepEqual(etiquettesDe(executer('changerEtiquettes', chargeEssai(), { id: 3, ajouter: 'a|b' }).charge, 3), ['a', 'b']);
});
test("l'orthographe existante d'une personne est reçue, et le dit : ORTHOGRAPHE_RETENUE {saisie, retenue}", () => {
const rendu = executer('changerEtiquettes', chargeEtiquetee(), { id: 3, ajouter: 'FINANCE' });
assert.deepEqual(etiquettesDe(rendu.charge, 3), ['finance']);
assert.deepEqual(rendu.avertissements, [{ code: 'ORTHOGRAPHE_RETENUE', details: { saisie: 'FINANCE', retenue: 'finance' } }]);
});
test("deux orthographes d'une clé que l'événement ne porte pas se fondent dans la saisie, sans avertissement", () => {
const rendu = executer('changerEtiquettes', chargeEssai(), { id: 3, ajouter: 'Montréal, MONTRÉAL' });
assert.deepEqual(etiquettesDe(rendu.charge, 3), ['MONTRÉAL']);
assert.deepEqual(rendu.avertissements, []);
});
test("la réconciliation lit l'événement entier : une étiquette que seul un objectif porte donne son orthographe", () => {
const rendu = executer('changerEtiquettes', chargeEtiquetee(), { id: 4, ajouter: 'Ressources Humaines' });
assert.deepEqual(etiquettesDe(rendu.charge, 4), ['ressources humaines']);
assert.deepEqual(rendu.avertissements, [
{ code: 'ORTHOGRAPHE_RETENUE', details: { saisie: 'Ressources Humaines', retenue: 'ressources humaines' } },
]);
});
test("deux orthographes déjà présentes dans un fichier retouché : la plus petite par points de code est reçue", () => {
const charge = chargeEtiquetee();
charge.participants[1].etiquettes = ['Finance'];
assert.deepEqual(etiquettesDe(executer('changerEtiquettes', charge, { id: 3, ajouter: 'finance' }).charge, 3), ['Finance']);
});
test('retirer : la dernière étiquette retirée retire le champ ; une étiquette et sa nouvelle orthographe dans un même geste', () => {
const charge = chargeEtiquetee();
const retiree = executer('changerEtiquettes', charge, { id: 1, retirer: 'finance' });
assert.equal(Object.hasOwn(retiree.charge.participants[0], 'etiquettes'), false);
assert.equal(retiree.libelle, 'Étiquettes de Iris Ombrelle : retiré « finance »');
// Seule à porter « finance », Iris la renomme : l'orthographe retirée ne
// s'impose plus à la saisie.
const renommee = executer('changerEtiquettes', charge, { id: 1, retirer: 'finance', ajouter: 'Finance' });
assert.deepEqual(etiquettesDe(renommee.charge, 1), ['Finance']);
assert.deepEqual(renommee.avertissements, []);
assert.equal(renommee.libelle, 'Étiquettes de Iris Ombrelle : ajouté « Finance » ; retiré « finance »');
});
test('SANS_EFFET par comparaison d’ensembles : une étiquette déjà portée, son autre orthographe, une étiquette absente à retirer', () => {
const charge = chargeEtiquetee();
charge.participants[0].etiquettes = ['finance', 'voile'];
for (const args of [
{ id: 1, ajouter: 'voile, finance' },
{ id: 1, ajouter: 'Finance' },
{ id: 1, retirer: 'escalade' },
{ id: 1, retirer: 'finance', ajouter: 'finance' },
]) {
refuse(() => executer('changerEtiquettes', charge, args), 'SANS_EFFET', { commande: 'changerEtiquettes', remede: null });
}
});
test('une étiquette mal formée est refusée ETIQUETTE_INVALIDE {valeur, raison}, remède null ; une saisie vide aussi', () => {
const charge = chargeEssai();
refuse(() => executer('changerEtiquettes', charge, { id: 3, ajouter: 'voile, a\u{0009}b' }), 'ETIQUETTE_INVALIDE', {
valeur: 'a\u{0009}b',
raison: 'controle',
remede: null,
});
const longue = 'x'.repeat(41);
refuse(() => executer('changerEtiquettes', charge, { id: 3, ajouter: longue }), 'ETIQUETTE_INVALIDE', {
valeur: longue,
raison: 'longueur',
remede: null,
});
for (const vide of ['', ' ', ' ,; | ']) {
refuse(() => executer('changerEtiquettes', charge, { id: 3, ajouter: vide }), 'ETIQUETTE_INVALIDE', { valeur: '', raison: 'vide', remede: null });
}
assert.deepEqual(etiquettesDe(executer('changerEtiquettes', charge, { id: 3, ajouter: 'x'.repeat(40) }).charge, 3), ['x'.repeat(40)]);
});
test('une vingt-et-unième étiquette est refusée ETIQUETTES_EN_TROP {nombre}, remède null ; la vingtième passe', () => {
const charge = chargeEssai();
charge.participants[2].etiquettes = Array.from({ length: 19 }, (_, rang) => `e${String(rang).padStart(2, '0')}`);
assert.equal(etiquettesDe(executer('changerEtiquettes', charge, { id: 3, ajouter: 'voile' }).charge, 3).length, 20);
refuse(() => executer('changerEtiquettes', charge, { id: 3, ajouter: 'voile, escalade' }), 'ETIQUETTES_EN_TROP', { nombre: 21, remede: null });
});
test('les mêmes étiquettes sous deux ordres de saisie s’écrivent pareil', () => {
const une = executer('changerEtiquettes', chargeEssai(), { id: 3, ajouter: 'voile, Montréal, escalade' }).charge;
let autre = executer('changerEtiquettes', chargeEssai(), { id: 3, ajouter: 'escalade' }).charge;
autre = executer('changerEtiquettes', autre, { id: 3, ajouter: 'Montréal | voile' }).charge;
assert.equal(serialiserCharge(une), serialiserCharge(autre));
assert.deepEqual(etiquettesDe(une, 3), etiquettesDe(autre, 3));
});
test("personne inconnue, plan bloqué, arguments de forme fausse", () => {
refuse(() => executer('changerEtiquettes', chargeEssai(), { id: 42, ajouter: 'voile' }), 'PARTICIPANT_INCONNU', { id: 42, remede: null });
const bloquee = { ...chargeEssai(), evenement: { ...chargeEssai().evenement, etat: 'bloque' } };
refuse(() => executer('changerEtiquettes', bloquee, { id: 3, ajouter: 'voile' }), 'PLAN_BLOQUE', { remede: { geste: 'debloquer' } });
for (const args of [{ id: 3 }, { id: 3, ajouter: 4 }, { id: 3, retirer: ['voile'] }, { id: 3, retirer: null }]) {
assert.throws(() => executer('changerEtiquettes', chargeEssai(), args), TypeError, JSON.stringify(args));
}
});
test('changer les étiquettes ne met aucune proposition en dérive (D13)', () => {
const charge = chargeRiche();
const apres = executer('changerEtiquettes', charge, { id: 2, ajouter: 'finance' }).charge;
for (const placement of [...charge.propositions, charge.retenu]) {
assert.deepEqual(derive(placement, apres), derive(placement, charge));
}
});
});
describe("commandes : l'import (§ 10.1)", () => {
test('ajouter : chaque ligne valide devient une personne, sous le compteur, en une seule commande', () => {
const avant = chargeEssai();
@ -606,6 +736,7 @@ describe('commandes : la charge reçue ne change jamais (§ 13.2)', () => {
['debloquer', { ...charge, evenement: { ...charge.evenement, etat: 'bloque' } }],
['convertirEnTous', reservee],
['deplacerDansRetenu', executer('retirerDuTour', charge, { tour: 1, participant: autre }).charge],
['retirerObjectif', executer('poserObjectif', charge, { table: 2, tour: null, etiquettes: 'finance', regle: null }).charge],
]);
}
@ -643,6 +774,10 @@ describe('commandes : la charge reçue ne change jamais (§ 13.2)', () => {
['changerTours', { tours: 3 }],
['modifierReglage', { cle: 'nouveauxVoisins', valeur: false }],
['changerCibleRedondance', { cible: '1200' }],
['changerEtiquettes', { id: 3, ajouter: 'finance' }],
['poserObjectif', { table: 2, tour: 1, etiquettes: 'finance', regle: 'et' }],
['retirerObjectif', { table: 2, tour: null }],
['changerRegleObjectifs', { regle: 'et' }],
]);
}
@ -690,8 +825,8 @@ describe('commandes : les registres réunis et les erreurs partagées', () => {
);
});
test("COMMANDES réunit les onze commandes d'A1 et les deux registres de F1, vides tant que leurs tâches ne les remplissent pas", () => {
assert.equal(COMMANDES.size, 11 + COMMANDES_MOBILIER.size + COMMANDES_PLACES.size);
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);
assert.ok(COMMANDES_MOBILIER instanceof Map);
assert.ok(COMMANDES_PLACES instanceof Map);
for (const registre of [COMMANDES_MOBILIER, COMMANDES_PLACES]) {

View file

@ -2,18 +2,20 @@
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le registre des commandes du mobilier — tables, sièges, tours, réglages,
// cible de la somme de redondance (§ 4, § 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.
// 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
// réuni dépend de lui, non l'inverse.
//
// Chaque commande suit le contrat de commandes.js : une fonction pure
// (charge, arguments) → { charge, libelle, avertissements }, la charge reçue
// intacte ; un refus lève ErreurCommande(code, details), details.remede
// compris, calculé avant toute modification ; un argument de forme fausse ou
// une table inconnue lèvent TypeError, que l'interface n'offre jamais. Un
// plan bloqué refuse tout (PLAN_BLOQUE). Une saisie de places ou de tours se
// lit par lireEntier, la seule lecture d'un entier saisi.
// une table inconnue lèvent TypeError, que l'interface n'offre jamais, sauf
// pour un objectif : la table qu'il vise a pu disparaître pendant la saisie
// de ses étiquettes, et TABLE_DISPARUE le dit. Un plan bloqué refuse tout
// (PLAN_BLOQUE). Une saisie de places ou de tours se lit par lireEntier, la
// seule lecture d'un entier saisi.
//
// Une table a un identifiant, tiré de prochainsIds.table, qui ne revient
// jamais, et un numéro affiché, le plus grand plus un, qui ne se renumérote
@ -23,12 +25,22 @@
// 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 { CIBLE_REDONDANCE_PAR_DEFAUT, SCHEMA, TOURS_MAX, capacite, cibleRedondance } from '../stockage/document.js';
import {
CIBLE_REDONDANCE_PAR_DEFAUT,
SCHEMA,
TOURS_MAX,
capacite,
cibleRedondance,
critereCoche,
regleObjectifs,
} from '../stockage/document.js';
import { ETIQUETTES_MAX } from '../stockage/etiquettes.js';
import { fautes } from '../stockage/placements.js';
import { bilanChangementDefaut } from './bilans.js';
import { controlerCapacite, lireEntier, retenuAuxCapacites } from './capacite.js';
import { ErreurCommande, refus } from './erreurs.js';
import { libelle } from './libelles.js';
import { avisDeLObjectif, avisObjectifs, etiquettesDeLEvenement, ordreDesObjectifs, recevoirEtiquettes } from './objectifs.js';
import { leverRefus } from './pose.js';
const FORMES = new Set(['ronde', 'carree']);
@ -43,6 +55,13 @@ const IDENTIFIANT_MAX = regleDuChamp(regleDuChamp(regleDuChamp(SCHEMA, 'charge')
// de la génération (§ 5.3, § 5.10).
const INTERRUPTEURS = new Set(['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable', 'varierAppartenances', 'attribuerSieges']);
const ENTIERS_DE_GENERATION = new Set(['generation.nombre', 'generation.arret', 'generation.historique']);
// Les deux cases facultatives du format 2 (§ 5.3, § 8.9) : absentes des
// réglages à leur défaut, que critereCoche donne seul — lu sur des réglages
// qui ne les portent pas.
const CASES_FACULTATIVES = new Set(['respecterObjectifs', 'rencontrerAnimateurs']);
const defautDeLaCase = (cle) => critereCoche({ reglages: {} }, cle);
const REGLES = new Set(['ou', 'et']);
// Un plan bloqué refuse toute modification ; débloquer seul le lève (§ 9).
function exigerModifiable(charge) {
@ -179,9 +198,9 @@ function creerTable(charge, { forme } = {}) {
/**
* supprimerTable {id} (§ 6.2, § 9) : la table part, et avec elle, dans la
* même entrée, ses réservations — suspendues comprises — et ses titres ; les
* autres tables gardent leur numéro, propositions et retenu restent, en
* dérive TABLE_SUPPRIMEE.
* même entrée, ses réservations — suspendues comprises —, ses titres et ses
* objectifs, qu'elle porte ; les autres tables gardent leur numéro,
* propositions et retenu restent, en dérive TABLE_SUPPRIMEE.
*/
function supprimerTable(charge, { id } = {}) {
exigerModifiable(charge);
@ -194,6 +213,7 @@ function supprimerTable(charge, { id } = {}) {
numero: table.numero,
reservations: charge.reservations.length - suivante.reservations.length,
titres: charge.titres.length - suivante.titres.length,
objectifs: table.objectifs?.length ?? 0,
};
return { charge: suivante, libelle: libelle('ENTREE_SUPPRIMER_TABLE', details), avertissements: [] };
}
@ -281,12 +301,15 @@ function changerDefaut(charge, { sieges: saisie, realigner } = {}) {
}
/**
* changerTours {tours} (§ 2.1 étape 5, § 4.3) : le nombre de tours, lu par
* lireEntier ; MOINS_D_UN_TOUR {tours} sous 1, TROP_DE_TOURS {max} au-delà
* de TOURS_MAX, la borne que l'analyse pose aussi, remède null. Retirer des tours
* détruit les réservations « tour désigné » au-delà, dans la même entrée ;
* la portée « tous » reste, symbolique. Propositions et retenu restent, en
* dérive TOURS_CHANGES, jamais tronqués.
* changerTours {tours} (§ 2.1 étape 5, § 4.3, § 4.5) : le nombre de tours,
* lu par lireEntier ; MOINS_D_UN_TOUR {tours} sous 1, TROP_DE_TOURS {max}
* au-delà de TOURS_MAX, la borne que l'analyse pose aussi, remède null.
* Retirer des tours détruit, dans la même entrée, les réservations « tour
* désigné » et les objectifs de table de ces tours, que le libellé compte ;
* une table qui n'en garde aucun perd son champ objectifs. La portée
* « tous » reste, symbolique, et l'objectif de la soirée aussi. Remettre le
* tour ne rend rien de ce qui est détruit. Propositions et retenu restent,
* en dérive TOURS_CHANGES, jamais tronqués.
*/
function changerTours(charge, { tours: saisie } = {}) {
exigerModifiable(charge);
@ -296,16 +319,31 @@ function changerTours(charge, { tours: saisie } = {}) {
const suivante = structuredClone(charge);
suivante.evenement.tours = tours;
suivante.reservations = suivante.reservations.filter(({ portee, tour }) => portee === 'tous' || tour <= tours);
let objectifsRetires = 0;
for (const table of suivante.tables) {
if (table.objectifs === undefined) continue;
const gardes = table.objectifs.filter(({ portee, tour }) => portee === 'tous' || tour <= tours);
objectifsRetires += table.objectifs.length - gardes.length;
if (gardes.length > 0) table.objectifs = gardes;
else delete table.objectifs;
}
return {
charge: suivante,
libelle: libelle('ENTREE_CHANGER_TOURS', { tours, retirees: charge.reservations.length - suivante.reservations.length }),
libelle: libelle('ENTREE_CHANGER_TOURS', {
tours,
retirees: charge.reservations.length - suivante.reservations.length,
objectifsRetires,
}),
avertissements: [],
};
}
/**
* modifierReglage {cle, valeur} (§ 5.3, § 15.4) : un interrupteur des
* contraintes ou de l'attribution des sièges, un booléen ; ou generation.nombre,
* modifierReglage {cle, valeur} (§ 5.3, § 8.9, § 15.4) : un interrupteur des
* 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,
* 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.
@ -316,6 +354,11 @@ function modifierReglage(charge, { cle, valeur } = {}) {
if (INTERRUPTEURS.has(cle)) {
if (typeof valeur !== 'boolean') throw new TypeError(`${cle} : booléen attendu, reçu ${JSON.stringify(valeur)}`);
suivante.reglages[cle] = valeur;
} else if (CASES_FACULTATIVES.has(cle)) {
if (typeof valeur !== 'boolean') throw new TypeError(`${cle} : booléen attendu, reçu ${JSON.stringify(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 (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;
@ -346,6 +389,126 @@ function changerCibleRedondance(charge, { cible: saisie } = {}) {
return { charge: suivante, libelle: libelle('ENTREE_CHANGER_CIBLE_REDONDANCE', { cible }), avertissements: [] };
}
// L'enregistrement de la table d'identifiant id qu'un objectif vise, ou
// TABLE_DISPARUE {table}, remède null : aucun geste ne rend une table
// supprimée.
function tableDeLObjectif(charge, id) {
const table = charge.tables.find((candidate) => candidate.id === id);
if (table === undefined) throw refus('TABLE_DISPARUE', { table: id });
return table;
}
// Le tour d'un objectif, null pour la soirée ou un entier ; une autre valeur
// est une faute du code. Un entier hors de 1 à R est OBJECTIF_HORS_SOIREE
// {tour}, remède null : le tour se choisit parmi ceux de la soirée.
function tourDeLObjectif(charge, tour) {
if (tour !== null && !Number.isSafeInteger(tour)) {
throw new TypeError(`tour : null ou entier attendu, reçu ${JSON.stringify(tour)}`);
}
if (tour !== null && (tour < 1 || tour > charge.evenement.tours)) throw refus('OBJECTIF_HORS_SOIREE', { tour });
return tour;
}
// La charge reçue, copiée, la table id portant les objectifs que rend
// modifier de sa liste — sans eux, le champ retiré —, rangés la soirée
// d'abord, puis les tours croissants (§ 8.9).
function avecObjectifs(charge, id, modifier) {
const suivante = structuredClone(charge);
const table = suivante.tables.find((candidate) => candidate.id === id);
const objectifs = modifier(table.objectifs ?? []).sort(ordreDesObjectifs);
if (objectifs.length > 0) table.objectifs = objectifs;
else delete table.objectifs;
return suivante;
}
// Deux listes d'étiquettes portent le même ensemble.
const memesEtiquettes = (a, b) => a.length === b.length && a.every((motCle) => b.includes(motCle));
/**
* poserObjectif {table, tour, etiquettes, regle} (§ 4.5, § 5.9) : l'objectif
* de la table d'identifiant table pour la soirée (tour null) ou pour le tour
* donné, qui remplace celui du même couple. etiquettes est une saisie, reçue
* (recevoirEtiquettes) sur les étiquettes de l'événement entier sans
* l'objectif remplacé ; regle, 'ou' ou 'et' pour une règle propre, null pour
* suivre le défaut. Le même objectif — même règle, même ensemble
* d'étiquettes — est SANS_EFFET. Refus : TABLE_DISPARUE {table} ;
* OBJECTIF_HORS_SOIREE {tour} ; ETIQUETTE_INVALIDE {valeur, raison} ;
* OBJECTIF_SANS_ETIQUETTE {table, tour}, une saisie qui ne donne aucune
* étiquette, de remède « Retirer l'objectif » quand le couple en porte un —
* vider la liste, c'est le retirer —, null sinon ; ETIQUETTES_EN_TROP
* {nombre} au-delà d'ETIQUETTES_MAX. Avertissements : ORTHOGRAPHE_RETENUE,
* puis les avis de l'objectif posé (avisDeLObjectif), acceptés et signalés.
*/
function poserObjectif(charge, { table: id, tour: saisi, etiquettes: texte, regle } = {}) {
exigerModifiable(charge);
if (regle !== null && !REGLES.has(regle)) throw new TypeError(`regle : 'ou', 'et' ou null attendu, reçu ${JSON.stringify(regle)}`);
const table = tableDeLObjectif(charge, id);
const tour = tourDeLObjectif(charge, saisi);
const ancien = (table.objectifs ?? []).find((objectif) => objectif.tour === tour);
const sansLui = avecObjectifs(charge, id, (objectifs) => objectifs.filter((objectif) => objectif.tour !== tour));
const recues = recevoirEtiquettes(etiquettesDeLEvenement(sansLui), texte);
if (recues.etiquettes.length === 0) {
const remede = ancien === undefined ? null : { commande: 'retirerObjectif', arguments: { table: id, tour } };
throw refus('OBJECTIF_SANS_ETIQUETTE', { table: id, tour }, remede);
}
if (recues.etiquettes.length > ETIQUETTES_MAX) throw refus('ETIQUETTES_EN_TROP', { nombre: recues.etiquettes.length });
if (ancien !== undefined && ancien.regle === regle && memesEtiquettes(ancien.etiquettes, recues.etiquettes)) {
throw refus('SANS_EFFET', { commande: 'poserObjectif' });
}
const objectif = { portee: tour === null ? 'tous' : 'tour', tour, regle, etiquettes: recues.etiquettes };
const suivante = avecObjectifs(sansLui, id, (objectifs) => [...objectifs, objectif]);
return {
charge: suivante,
libelle: libelle('ENTREE_POSER_OBJECTIF', { numero: table.numero, tour, regle, etiquettes: recues.etiquettes }),
avertissements: [...recues.avertissements, ...avisDeLObjectif(suivante, id, tour)],
};
}
/**
* retirerObjectif {table, tour} (§ 4.5, D19) : l'objectif de la soirée (tour
* null) ou du tour donné part, sans fenêtre à confirmer : un retour à
* l'instant précédent le rend. Le dernier retiré retire le champ objectifs.
* Un couple sans objectif est SANS_EFFET ; TABLE_DISPARUE {table} pour une
* table supprimée.
*/
function retirerObjectif(charge, { table: id, tour } = {}) {
exigerModifiable(charge);
if (tour !== null && !Number.isSafeInteger(tour)) throw new TypeError(`tour : null ou entier attendu, reçu ${JSON.stringify(tour)}`);
const table = tableDeLObjectif(charge, id);
if (!(table.objectifs ?? []).some((objectif) => objectif.tour === tour)) throw refus('SANS_EFFET', { commande: 'retirerObjectif' });
return {
charge: avecObjectifs(charge, id, (objectifs) => objectifs.filter((objectif) => objectif.tour !== tour)),
libelle: libelle('ENTREE_RETIRER_OBJECTIF', { numero: table.numero, tour }),
avertissements: [],
};
}
// Un avis rendu comparable : son code et ses détails, en texte.
const cleDAvis = ({ code, details }) => JSON.stringify([code, details]);
/**
* changerRegleObjectifs {regle} (§ 4.5, § 8.9, D3) : la règle par défaut des
* objectifs, 'ou' ou 'et' ; le défaut, que regleObjectifs lit sur des
* réglages qui ne la portent pas, retire le champ des réglages, et la règle
* courante (regleObjectifs) est SANS_EFFET. Aucune règle propre ne change.
* Avertissements : les avis que le changement fait naître (avisObjectifs),
* ceux d'un objectif qui suit le défaut et ne sert plus personne.
*/
function changerRegleObjectifs(charge, { regle } = {}) {
exigerModifiable(charge);
if (!REGLES.has(regle)) throw new TypeError(`regle : 'ou' ou 'et' attendu, reçu ${JSON.stringify(regle)}`);
if (regle === regleObjectifs(charge)) throw refus('SANS_EFFET', { commande: 'changerRegleObjectifs' });
const suivante = structuredClone(charge);
if (regle === regleObjectifs({ reglages: {} })) delete suivante.reglages.regleObjectifs;
else suivante.reglages.regleObjectifs = regle;
const connus = new Set(avisObjectifs(charge).map(cleDAvis));
return {
charge: suivante,
libelle: libelle('ENTREE_CHANGER_REGLE_OBJECTIFS', { regle }),
avertissements: avisObjectifs(suivante).filter((avis) => !connus.has(cleDAvis(avis))),
};
}
/** @type {Map<string, (charge: Object, args: Object) => Object>} */
export const COMMANDES_MOBILIER = new Map([
['creerTable', creerTable],
@ -357,4 +520,7 @@ export const COMMANDES_MOBILIER = new Map([
['changerTours', changerTours],
['modifierReglage', modifierReglage],
['changerCibleRedondance', changerCibleRedondance],
['poserObjectif', poserObjectif],
['retirerObjectif', retirerObjectif],
['changerRegleObjectifs', changerRegleObjectifs],
]);

View file

@ -214,7 +214,7 @@ async function seanceSur(charge) {
// --- Les épreuves -----------------------------------------------------------
describe('mobilier : le registre (§ 8.2)', () => {
test('COMMANDES_MOBILIER porte les neuf commandes du mobilier, réunies dans COMMANDES', () => {
test('COMMANDES_MOBILIER porte les douze commandes du mobilier, réunies dans COMMANDES', () => {
const noms = [
'creerTable',
'supprimerTable',
@ -225,6 +225,9 @@ describe('mobilier : le registre (§ 8.2)', () => {
'changerTours',
'modifierReglage',
'changerCibleRedondance',
'poserObjectif',
'retirerObjectif',
'changerRegleObjectifs',
];
assert.deepEqual([...COMMANDES_MOBILIER.keys()], noms);
for (const nom of noms) assert.equal(COMMANDES.get(nom), COMMANDES_MOBILIER.get(nom), nom);
@ -243,6 +246,9 @@ describe('mobilier : le registre (§ 8.2)', () => {
['changerTours', { tours: 3 }],
['modifierReglage', { cle: 'nouveauxVoisins', valeur: false }],
['changerCibleRedondance', { cible: '1200' }],
['poserObjectif', { table: 1, tour: null, etiquettes: 'finance', regle: null }],
['retirerObjectif', { table: 1, tour: null }],
['changerRegleObjectifs', { regle: 'et' }],
];
assert.equal(gestes.length, COMMANDES_MOBILIER.size);
for (const [nom, args] of gestes) {
@ -1191,3 +1197,260 @@ describe('mobilier : la cible de la somme de redondance (§ 5.4, § 8.1, § 8.9)
assert.equal(explicite.reglages.cibleRedondance, 1000);
});
});
// --- Les objectifs des tables, leur règle et les deux cases (§ 4.5, § 5.3, § 8.9) ---
// Une charge de trois tables et quatre personnes sur trois tours : Iris
// Ombrelle « finance », Théo Pervenche « finance » et « Montréal », Ondine
// Lacasse « ressources humaines », la quatrième sans étiquette.
function chargeEtiquetee() {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3, numero: 7 }], n: 4, tours: 3 });
charge.participants[0].etiquettes = ['finance'];
charge.participants[1].etiquettes = ['Montréal', 'finance'];
charge.participants[2].etiquettes = ['ressources humaines'];
return charge;
}
const objectifsDe = (charge, id) => charge.tables.find((table) => table.id === id).objectifs;
const soiree = (etiquettes, regle = null) => ({ portee: 'tous', tour: null, regle, etiquettes });
const auTour = (tour, etiquettes, regle = null) => ({ portee: 'tour', tour, regle, etiquettes });
describe('mobilier : poser et retirer un objectif de table (§ 4.5, § 5.9, A2, A12)', () => {
test('poser un objectif de soirée : sa règle suit le défaut, ses étiquettes triées, le champ en dernier ; le libellé nomme la table par son numéro', () => {
const rendu = accepter('poserObjectif', chargeEtiquetee(), { table: 3, tour: null, etiquettes: 'finance ; Montréal', regle: null });
assert.deepEqual(objectifsDe(rendu.charge, 3), [soiree(['Montréal', 'finance'])]);
assert.equal(Object.keys(rendu.charge.tables[2]).at(-1), 'objectifs');
assert.equal(rendu.libelle, 'Table 7 : objectif de la soirée « Montréal » et « finance », règle par défaut');
assert.deepEqual(rendu.avertissements, []);
assert.equal(Object.hasOwn(rendu.charge.tables[0], 'objectifs'), false);
});
test("les objectifs d'une table s'écrivent la soirée d'abord, puis les tours croissants, quel que soit l'ordre des gestes", () => {
let une = chargeEtiquetee();
for (const [tour, etiquettes] of [[3, 'Montréal'], [null, 'finance'], [1, 'ressources humaines']]) {
une = accepter('poserObjectif', une, { table: 1, tour, etiquettes, regle: 'ou' }).charge;
}
let autre = chargeEtiquetee();
for (const [tour, etiquettes] of [[1, 'ressources humaines'], [3, 'Montréal'], [null, 'finance']]) {
autre = accepter('poserObjectif', autre, { table: 1, tour, etiquettes, regle: 'ou' }).charge;
}
const attendus = [soiree(['finance'], 'ou'), auTour(1, ['ressources humaines'], 'ou'), auTour(3, ['Montréal'], 'ou')];
assert.deepEqual(objectifsDe(une, 1), attendus);
assert.deepEqual(objectifsDe(autre, 1), attendus);
assert.equal(serialiser(une, { revision: 1, produitVersion: V1 }), serialiser(autre, { revision: 1, produitVersion: V1 }));
});
test("poser sur un couple qui en porte un le remplace, la règle seule aussi ; la même liste sous un autre ordre est SANS_EFFET, comparée en ensemble", () => {
const posee = accepter('poserObjectif', chargeEtiquetee(), { table: 2, tour: 2, etiquettes: 'finance, Montréal', regle: 'et' }).charge;
const remplacee = accepter('poserObjectif', posee, { table: 2, tour: 2, etiquettes: 'ressources humaines', regle: null });
assert.deepEqual(objectifsDe(remplacee.charge, 2), [auTour(2, ['ressources humaines'])]);
assert.equal(remplacee.libelle, 'Table 2, tour 2 : objectif « ressources humaines », règle par défaut');
refuse(() => COMMANDES.get('poserObjectif')(posee, { table: 2, tour: 2, etiquettes: 'Montréal | finance', regle: 'et' }), 'SANS_EFFET', {
commande: 'poserObjectif',
remede: null,
});
refuse(() => COMMANDES.get('poserObjectif')(posee, { table: 2, tour: 2, etiquettes: 'FINANCE, montreal', regle: 'et' }), 'SANS_EFFET');
// Les mêmes étiquettes sous une autre règle : le défaut cède à une règle
// propre, qui en change ensuite, et le libellé nomme la règle posée (D3).
const charge = chargeEtiquetee();
charge.tables[1].objectifs = [auTour(1, ['finance'])];
const propre = accepter('poserObjectif', charge, { table: 2, tour: 1, etiquettes: 'finance', regle: 'et' });
assert.deepEqual(objectifsDe(propre.charge, 2), [auTour(1, ['finance'], 'et')]);
assert.equal(propre.libelle, 'Table 2, tour 1 : objectif « finance », toutes les étiquettes');
const autre = accepter('poserObjectif', propre.charge, { table: 2, tour: 1, etiquettes: 'finance', regle: 'ou' });
assert.deepEqual(objectifsDe(autre.charge, 2), [auTour(1, ['finance'], 'ou')]);
assert.equal(autre.libelle, 'Table 2, tour 1 : objectif « finance », au moins une étiquette');
const defaut = accepter('poserObjectif', autre.charge, { table: 2, tour: 1, etiquettes: 'finance', regle: null });
assert.deepEqual(objectifsDe(defaut.charge, 2), [auTour(1, ['finance'])]);
});
test("la saisie se réconcilie sur l'événement entier, personnes et objectifs ; l'objectif remplacé ne s'impose pas", () => {
const charge = chargeEtiquetee();
charge.tables[0].objectifs = [soiree(['voile'])];
const rendu = accepter('poserObjectif', charge, { table: 2, tour: null, etiquettes: 'VOILE, Finance', regle: null });
assert.deepEqual(objectifsDe(rendu.charge, 2), [soiree(['finance', 'voile'])]);
assert.deepEqual(
rendu.avertissements.filter(({ code }) => code === 'ORTHOGRAPHE_RETENUE'),
[
{ code: 'ORTHOGRAPHE_RETENUE', details: { saisie: 'VOILE', retenue: 'voile' } },
{ code: 'ORTHOGRAPHE_RETENUE', details: { saisie: 'Finance', retenue: 'finance' } },
],
);
// Seul à porter « voile », l'objectif de la table 1 change d'orthographe
// en se reposant.
const renomme = accepter('poserObjectif', charge, { table: 1, tour: null, etiquettes: 'Voile', regle: null });
assert.deepEqual(objectifsDe(renomme.charge, 1), [soiree(['Voile'])]);
assert.deepEqual(renomme.avertissements, [
{ code: 'OBJECTIF_SANS_CORRESPONDANT', details: { table: 1, tour: null } },
{ code: 'ETIQUETTE_ORPHELINE', details: { etiquette: 'Voile', voisine: null } },
]);
});
test("les avis de l'objectif posé, et de lui seul : OBJECTIF_SANS_CORRESPONDANT, puis ETIQUETTE_ORPHELINE ; acceptés", () => {
const charge = chargeEtiquetee();
charge.tables[1].objectifs = [soiree(['plongée'])];
const rendu = accepter('poserObjectif', charge, { table: 1, tour: 2, etiquettes: 'ressources humaines, Montréal', regle: 'et' });
assert.deepEqual(rendu.avertissements, [{ code: 'OBJECTIF_SANS_CORRESPONDANT', details: { table: 1, tour: 2 } }]);
const orpheline = accepter('poserObjectif', charge, { table: 1, tour: null, etiquettes: 'plongée, escalade, finance', regle: null });
assert.deepEqual(orpheline.avertissements, [
{ code: 'ETIQUETTE_ORPHELINE', details: { etiquette: 'escalade', voisine: null } },
{ code: 'ETIQUETTE_ORPHELINE', details: { etiquette: 'plongée', voisine: null } },
]);
// Une orthographe qu'une personne porte est reçue : l'étiquette n'est pas
// orpheline.
charge.participants[3].etiquettes = ['Escalade'];
const recue = accepter('poserObjectif', charge, { table: 1, tour: null, etiquettes: 'escalade', regle: null });
assert.deepEqual(recue.avertissements, [{ code: 'ORTHOGRAPHE_RETENUE', details: { saisie: 'escalade', retenue: 'Escalade' } }]);
});
test('un tour hors de la soirée : OBJECTIF_HORS_SOIREE {tour}, remède null ; un tour non entier est une faute du code', () => {
const charge = chargeEtiquetee();
for (const tour of [0, 4]) {
refuse(() => COMMANDES.get('poserObjectif')(charge, { table: 1, tour, etiquettes: 'finance', regle: null }), 'OBJECTIF_HORS_SOIREE', {
tour,
remede: null,
});
}
for (const tour of [2.5, '2', undefined]) {
assert.throws(() => COMMANDES.get('poserObjectif')(charge, { table: 1, tour, etiquettes: 'finance', regle: null }), TypeError);
}
for (const regle of ['OU', 'toutes', undefined]) {
assert.throws(() => COMMANDES.get('poserObjectif')(charge, { table: 1, tour: null, etiquettes: 'finance', regle }), TypeError);
}
});
test("une saisie sans étiquette : OBJECTIF_SANS_ETIQUETTE {table, tour}, remède « Retirer l'objectif » quand il existe", () => {
const charge = chargeEtiquetee();
refuse(() => COMMANDES.get('poserObjectif')(charge, { table: 1, tour: 2, etiquettes: ' ;, ', regle: null }), 'OBJECTIF_SANS_ETIQUETTE', {
table: 1,
tour: 2,
remede: null,
});
charge.tables[0].objectifs = [auTour(2, ['finance'])];
refuse(() => COMMANDES.get('poserObjectif')(charge, { table: 1, tour: 2, etiquettes: '', regle: null }), 'OBJECTIF_SANS_ETIQUETTE', {
table: 1,
tour: 2,
remede: { commande: 'retirerObjectif', arguments: { table: 1, tour: 2 } },
});
});
test('étiquette mal formée, vingt-et-unième étiquette : les refus de la saisie, remède null', () => {
const charge = chargeEtiquetee();
refuse(() => COMMANDES.get('poserObjectif')(charge, { table: 1, tour: null, etiquettes: 'finance, x\u{200B}y', regle: null }), 'ETIQUETTE_INVALIDE', {
valeur: 'x\u{200B}y',
raison: 'controle',
remede: null,
});
const vingt = Array.from({ length: 20 }, (_, rang) => `e${rang}`).join(', ');
assert.equal(objectifsDe(accepter('poserObjectif', charge, { table: 1, tour: null, etiquettes: vingt, regle: null }).charge, 1)[0].etiquettes.length, 20);
refuse(() => COMMANDES.get('poserObjectif')(charge, { table: 1, tour: null, etiquettes: `${vingt}, e20`, regle: null }), 'ETIQUETTES_EN_TROP', {
nombre: 21,
remede: null,
});
});
test("retirer : le dernier objectif retiré retire le champ ; un objectif absent est SANS_EFFET ; libellé par numéro", () => {
const charge = chargeEtiquetee();
charge.tables[2].objectifs = [soiree(['finance']), auTour(2, ['Montréal'], 'et')];
const tour = accepter('retirerObjectif', charge, { table: 3, tour: 2 });
assert.deepEqual(objectifsDe(tour.charge, 3), [soiree(['finance'])]);
assert.equal(tour.libelle, "Table 7, tour 2 : objectif retiré");
const tout = accepter('retirerObjectif', tour.charge, { table: 3, tour: null });
assert.equal(Object.hasOwn(tout.charge.tables[2], 'objectifs'), false);
assert.equal(tout.libelle, 'Table 7 : objectif de la soirée retiré');
assert.equal(serialiser(tout.charge, { revision: 1, produitVersion: V1 }), serialiser(chargeEtiquetee(), { revision: 1, produitVersion: V1 }));
refuse(() => COMMANDES.get('retirerObjectif')(charge, { table: 3, tour: 1 }), 'SANS_EFFET', { commande: 'retirerObjectif', remede: null });
assert.throws(() => COMMANDES.get('retirerObjectif')(charge, { table: 3, tour: '2' }), TypeError);
});
test('une table disparue : TABLE_DISPARUE {table}, remède null, pour poser comme pour retirer', () => {
const charge = chargeEtiquetee();
refuse(() => COMMANDES.get('poserObjectif')(charge, { table: 9, tour: null, etiquettes: 'finance', regle: null }), 'TABLE_DISPARUE', {
table: 9,
remede: null,
});
refuse(() => COMMANDES.get('retirerObjectif')(charge, { table: 9, tour: null }), 'TABLE_DISPARUE', { table: 9, remede: null });
});
test("supprimer une table emporte ses objectifs, que le libellé compte", () => {
const charge = chargeEtiquetee();
charge.tables[1].objectifs = [soiree(['finance']), auTour(1, ['Montréal'])];
const rendu = accepter('supprimerTable', charge, { id: 2 });
assert.equal(rendu.libelle, 'Supprimé la table 2 ; retirés : 2 objectifs');
});
});
describe("mobilier : la règle par défaut des objectifs (§ 4.5, D3)", () => {
test("« toutes » se pose dans les réglages ; « au moins une » retire le champ ; la même règle est SANS_EFFET", () => {
const charge = chargeEtiquetee();
const et = accepter('changerRegleObjectifs', charge, { regle: 'et' });
assert.equal(et.charge.reglages.regleObjectifs, 'et');
assert.equal(et.libelle, 'Règle par défaut des objectifs : toutes les étiquettes');
const ou = accepter('changerRegleObjectifs', et.charge, { regle: 'ou' });
assert.equal(Object.hasOwn(ou.charge.reglages, 'regleObjectifs'), false);
assert.equal(ou.libelle, 'Règle par défaut des objectifs : au moins une étiquette');
assert.equal(serialiser(ou.charge, { revision: 1, produitVersion: V1 }), serialiser(charge, { revision: 1, produitVersion: V1 }));
refuse(() => COMMANDES.get('changerRegleObjectifs')(charge, { regle: 'ou' }), 'SANS_EFFET', { commande: 'changerRegleObjectifs', remede: null });
refuse(() => COMMANDES.get('changerRegleObjectifs')(et.charge, { regle: 'et' }), 'SANS_EFFET');
for (const regle of ['ET', null, undefined]) assert.throws(() => COMMANDES.get('changerRegleObjectifs')(charge, { regle }), TypeError);
});
test("changer le défaut ne touche aucune règle propre, et dit les objectifs qui le suivent et ne servent plus personne", () => {
const charge = chargeEtiquetee();
charge.tables[0].objectifs = [soiree(['finance', 'ressources humaines'])];
charge.tables[1].objectifs = [soiree(['finance', 'ressources humaines'], 'ou')];
// La table 7 porte déjà ses avis avant le changement : ils ne se redisent
// pas, seuls ceux que le changement fait naître sont rendus.
charge.tables[2].objectifs = [soiree(['plongée'])];
const rendu = accepter('changerRegleObjectifs', charge, { regle: 'et' });
assert.deepEqual(objectifsDe(rendu.charge, 1), objectifsDe(charge, 1));
assert.deepEqual(objectifsDe(rendu.charge, 2), objectifsDe(charge, 2));
assert.deepEqual(rendu.avertissements, [{ code: 'OBJECTIF_SANS_CORRESPONDANT', details: { table: 1, tour: null } }]);
assert.deepEqual(accepter('changerRegleObjectifs', rendu.charge, { regle: 'ou' }).avertissements, []);
});
});
describe('mobilier : les cases des objectifs et des animateurs (§ 5.3, § 8.9)', () => {
test('chaque case ne s’écrit que hors de son défaut, et revenir au défaut retire le champ', () => {
const charge = chargeEtiquetee();
const decochee = accepter('modifierReglage', charge, { cle: 'respecterObjectifs', valeur: false });
assert.equal(decochee.charge.reglages.respecterObjectifs, false);
assert.equal(decochee.libelle, 'Respecter les objectifs des tables : désactivé');
const recochee = accepter('modifierReglage', decochee.charge, { cle: 'respecterObjectifs', valeur: true });
assert.equal(Object.hasOwn(recochee.charge.reglages, 'respecterObjectifs'), false);
const cochee = accepter('modifierReglage', charge, { cle: 'rencontrerAnimateurs', valeur: true });
assert.equal(cochee.charge.reglages.rencontrerAnimateurs, true);
assert.equal(cochee.libelle, "Rencontrer le plus d'animateurs différents : activé");
const decocheeAnim = accepter('modifierReglage', cochee.charge, { cle: 'rencontrerAnimateurs', valeur: false });
assert.equal(Object.hasOwn(decocheeAnim.charge.reglages, 'rencontrerAnimateurs'), false);
for (const fin of [recochee.charge, decocheeAnim.charge]) {
assert.equal(serialiser(fin, { revision: 1, produitVersion: V1 }), serialiser(charge, { revision: 1, produitVersion: V1 }));
}
});
test('une case à sa valeur courante est SANS_EFFET, sans écrire son défaut ; une valeur non booléenne est une faute du code', () => {
const charge = chargeEtiquetee();
for (const [cle, valeur] of [['respecterObjectifs', true], ['rencontrerAnimateurs', false]]) {
refuse(() => COMMANDES.get('modifierReglage')(charge, { cle, valeur }), 'SANS_EFFET', { commande: 'modifierReglage', remede: null });
assert.throws(() => COMMANDES.get('modifierReglage')(charge, { cle, valeur: 'oui' }), TypeError);
}
});
});
describe('mobilier : retirer des tours détruit leurs objectifs (§ 2.1 étape 5, A3)', () => {
test('3 → 2 : les objectifs du tour 3 partent, ceux de la soirée et des tours restants demeurent ; le libellé les compte avec les réservations', () => {
const charge = chargeEtiquetee();
charge.tables[0].objectifs = [soiree(['finance']), auTour(3, ['Montréal'])];
charge.tables[1].objectifs = [auTour(3, ['finance'])];
charge.tables[2].objectifs = [auTour(2, ['finance'])];
charge.reservations = [resa(1, 1, null, 3)];
const rendu = accepter('changerTours', charge, { tours: 2 });
assert.deepEqual(objectifsDe(rendu.charge, 1), [soiree(['finance'])]);
assert.equal(Object.hasOwn(rendu.charge.tables[1], 'objectifs'), false);
assert.deepEqual(objectifsDe(rendu.charge, 3), [auTour(2, ['finance'])]);
assert.equal(rendu.libelle, 'Nombre de tours : 2 ; 1 réservation retirée, 2 objectifs retirés');
// Remettre le tour ne rend pas ses objectifs : ils sont détruits.
const remis = accepter('changerTours', rendu.charge, { tours: 3 }).charge;
assert.deepEqual(objectifsDe(remis, 1), [soiree(['finance'])]);
assert.equal(accepter('changerTours', charge, { tours: 4 }).libelle, 'Nombre de tours : 4');
});
});

View file

@ -0,0 +1,72 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le pont de la charge vers le moteur (§ 4.5, § 5.2, D6) : la configuration
// que lit le stockage, plus qui anime à chaque tour. Le stockage n'importe
// pas la règle des titres pourvus, qui vit dans l'analyseur CSV
// (pourvoyeursDesTitres) : l'application la lit ici, sans la recopier. Sous
// src/application, ce module est le seul appelant de
// configurationDepuisCharge, et configurationDe la seule configuration du
// moteur tirée d'une charge.
//
// Une personne anime au tour r quand elle pourvoit un titre de place à ce
// tour, quels que soient le libellé du titre et la forme de ses
// réservations : une réservation « tous les tours » sur la place titrée fait
// animer à chaque tour, R réservations de tour désigné aussi, une seule
// réservation de tour désigné à son seul tour. La règle des titres pourvus
// écarte les exclus et lit le réglage « Attribuer les sièges » : sans sièges
// attribués, une réservation sans siège à une table titrée pourvoit un titre
// de cette table ; avec, seule une réservation à la place titrée le pourvoit.
import { pourvoyeursDesTitres } from '../csv/import.js';
import { configurationDepuisCharge } from '../stockage/document.js';
/**
* Les animateurs de la charge (D6), par identifiant croissant : chaque
* personne qui pourvoit un titre de place à un tour au moins, avec ses tours
* animés, de 1 à R, croissants et sans doublon — l'union, sur ses titres,
* des tours où elle les pourvoit. Lu sur pourvoyeursDesTitres, tour par
* tour : l'entrée tours[r − 1] d'un titre nomme qui le pourvoit au tour r ;
* la répartition de la soirée seule n'y entre pas, elle ne voit pas les
* réservations de tour désigné. Ne modifie pas la charge.
*
* @param {import('../stockage/types.js').Charge} charge
* @returns {Array<{id: number, tours: number[]}>}
*/
export function animateursDe(charge) {
const R = charge.evenement.tours;
// Par identifiant, un drapeau par tour : un titre pourvu deux fois au même
// tour par la même personne ne compte qu'une fois.
const anime = new Map();
for (const { tours } of pourvoyeursDesTitres(charge)) {
tours.forEach((reservation, rang) => {
if (reservation === null) return;
const { participant } = reservation;
if (!anime.has(participant)) anime.set(participant, new Array(R).fill(false));
anime.get(participant)[rang] = true;
});
}
return [...anime.keys()]
.sort((a, b) => a - b)
.map((id) => ({ id, tours: anime.get(id).flatMap((drapeau, rang) => (drapeau ? [rang + 1] : [])) }));
}
/**
* La configuration du moteur tirée d'une charge (contrat de données) : celle
* de configurationDepuisCharge, où chaque animateur porte en plus anime, ses
* tours animés (animateursDe), après ses autres clés. Les autres participants
* n'en portent pas : une charge sans titre pourvu rend la configuration du
* stockage telle quelle. Chaque liste de tours est neuve ; ne modifie pas la
* charge.
*
* @param {import('../stockage/types.js').Charge} charge
* @returns {import('../moteur/types.js').Configuration}
*/
export function configurationDe(charge) {
const configuration = configurationDepuisCharge(charge);
const toursDe = new Map(animateursDe(charge).map(({ id, tours }) => [id, tours]));
for (const participant of configuration.participants) {
const tours = toursDe.get(participant.id);
if (tours !== undefined) participant.anime = tours;
}
return configuration;
}

View file

@ -0,0 +1,239 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du pont de la charge vers le moteur (§ 4.5, § 5.2, D6) : qui
// anime, tour par tour, lu sur les titres pourvus ; la configuration du
// moteur, celle du stockage plus les tours animés ; les quatre
// démonstrations ; la garde qui fait de configuration.js le seul appelant de
// configurationDepuisCharge sous src/application. Les noms sont inventés.
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, test } from '../../test/lanceur.js';
import { LIVREES } from '../demo/livrees.js';
import { analyser, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
import { animateursDe, configurationDe } from './configuration.js';
// Une charge de trois tours, trois tables de quatre places et cinq
// personnes, sans titre ni réservation ; modifier la complète.
function chargeDe(modifier = () => {}) {
const charge = creerCharge({ id: 'evt-pont', nom: 'Cercle des Pervenches', siegesParDefaut: 4, tours: 3 });
const noms = [['Ombrelle', 'Iris'], ['Pervenche', 'Théo'], ['Lacasse', 'Ondine'], ['Brumaire', 'Anselme'], ['Givrette', 'Solène']];
charge.participants = noms.map(([nom, prenom], rang) => ({
id: rang + 1,
nom,
prenom,
appartenance: rang % 2 === 0 ? 'Atelier des Saules' : null,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
}));
charge.tables = [1, 2, 3].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 100 * id, y: 0 } }));
charge.prochainsIds = { participant: 6, table: 4, proposition: 1 };
modifier(charge);
return charge;
}
const titre = (table, siege, libelle = 'Animation') => ({ table, siege, libelle });
const reservation = (participant, table, siege, tour = null) =>
({ participant, table, siege, portee: tour === null ? 'tous' : 'tour', tour });
describe('animateursDe : qui anime, tour par tour (§ 4.5, D6)', () => {
test('aucun titre, ou des réservations sans titre : personne n’anime', () => {
assert.deepEqual(animateursDe(chargeDe()), []);
const ancres = chargeDe((c) => {
c.reservations = [reservation(1, 1, 1), reservation(2, 2, null)];
});
assert.deepEqual(animateursDe(ancres), []);
});
test('un titre pourvu au seul tour 2 fait animer au tour 2 seulement', () => {
const charge = chargeDe((c) => {
c.titres = [titre(1, 1)];
c.reservations = [reservation(3, 1, 1, 2)];
});
assert.deepEqual(animateursDe(charge), [{ id: 3, tours: [2] }]);
});
test('une réservation « tous les tours » sur la place titrée fait animer à chaque tour', () => {
const charge = chargeDe((c) => {
c.titres = [titre(2, 3)];
c.reservations = [reservation(4, 2, 3)];
});
assert.deepEqual(animateursDe(charge), [{ id: 4, tours: [1, 2, 3] }]);
});
test('R réservations de tour désigné sur la place titrée font animer à chaque tour, comme une seule de portée « tous »', () => {
const charge = chargeDe((c) => {
c.titres = [titre(1, 2)];
c.reservations = [reservation(2, 1, 2, 1), reservation(2, 1, 2, 2), reservation(2, 1, 2, 3)];
});
assert.deepEqual(animateursDe(charge), [{ id: 2, tours: [1, 2, 3] }]);
});
test('deux titres de la même personne à des tours différents : l’union de ses tours, croissante', () => {
const charge = chargeDe((c) => {
c.titres = [titre(1, 1), titre(3, 2)];
c.reservations = [reservation(1, 3, 2, 3), reservation(1, 1, 1, 1)];
});
assert.deepEqual(animateursDe(charge), [{ id: 1, tours: [1, 3] }]);
});
test('deux titres à la même place, pourvus par la même réservation : chaque tour une seule fois', () => {
const charge = chargeDe((c) => {
c.titres = [titre(2, 1, 'Accueil'), titre(2, 1, 'Animation')];
c.reservations = [reservation(5, 2, 1)];
});
assert.deepEqual(animateursDe(charge), [{ id: 5, tours: [1, 2, 3] }]);
});
test('une table à deux titres pourvus : deux animateurs aux mêmes tours', () => {
const charge = chargeDe((c) => {
c.titres = [titre(1, 1), titre(1, 2)];
c.reservations = [reservation(1, 1, 1), reservation(3, 1, 2, 2)];
});
assert.deepEqual(animateursDe(charge), [{ id: 1, tours: [1, 2, 3] }, { id: 3, tours: [2] }]);
});
test('une personne exclue n’anime pas : sa réservation est suspendue', () => {
const charge = chargeDe((c) => {
c.titres = [titre(1, 1), titre(2, 1)];
c.reservations = [reservation(1, 1, 1), reservation(2, 2, 1)];
c.participants[0].exclu = true;
});
assert.deepEqual(animateursDe(charge), [{ id: 2, tours: [1, 2, 3] }]);
});
test('sans sièges attribués, une réservation sans siège à une table titrée pourvoit le titre ; avec, elle ne le pourvoit pas (A8)', () => {
const charge = chargeDe((c) => {
c.titres = [titre(3, 2)];
c.reservations = [reservation(4, 3, null)];
});
assert.deepEqual(animateursDe(charge), [{ id: 4, tours: [1, 2, 3] }]);
charge.reglages.attribuerSieges = true;
assert.deepEqual(animateursDe(charge), []);
});
test('deux ancrés sans siège pour un titre : un seul animateur par titre, celui que la règle des titres désigne', () => {
const charge = chargeDe((c) => {
c.titres = [titre(1, 4)];
c.reservations = [reservation(5, 1, null), reservation(2, 1, null)];
});
assert.deepEqual(animateursDe(charge), [{ id: 2, tours: [1, 2, 3] }]);
});
test('les animateurs vont par identifiant croissant, quel que soit l’ordre des titres et des réservations', () => {
const modifier = (c) => {
c.titres = [titre(3, 1), titre(1, 1), titre(2, 2)];
c.reservations = [reservation(5, 1, 1), reservation(1, 3, 1, 3), reservation(3, 2, 2, 1)];
};
const attendu = [{ id: 1, tours: [3] }, { id: 3, tours: [1] }, { id: 5, tours: [1, 2, 3] }];
assert.deepEqual(animateursDe(chargeDe(modifier)), attendu);
const renversee = chargeDe(modifier);
renversee.titres.reverse();
renversee.reservations.reverse();
assert.deepEqual(animateursDe(renversee), attendu);
});
});
describe('configurationDe : la configuration du stockage, plus les tours animés (contrat de données)', () => {
test('chaque animateur porte anime, ses tours ; les autres participants et le reste de la configuration sont ceux du stockage', () => {
const charge = chargeDe((c) => {
c.titres = [titre(1, 1), titre(2, 1)];
c.reservations = [reservation(2, 1, 1, 3), reservation(4, 2, 1), reservation(5, 3, 2)];
c.participants[2].etiquettes = ['finance'];
});
const configuration = configurationDe(charge);
assert.deepEqual(configuration.participants.map(({ id, anime }) => [id, anime]), [
[1, undefined],
[2, [3]],
[3, undefined],
[4, [1, 2, 3]],
[5, undefined],
]);
assert.ok(configuration.participants.every((p) => p.anime !== undefined || !Object.hasOwn(p, 'anime')));
const sansAnime = {
...configuration,
participants: configuration.participants.map(({ anime, ...reste }) => reste),
};
assert.deepEqual(sansAnime, configurationDepuisCharge(charge));
assert.deepEqual(Object.keys(configuration.participants[1]), ['id', 'nom', 'appartenance', 'exclu', 'anime']);
});
test('sans titre pourvu, elle égale celle du stockage', () => {
const charge = chargeDe((c) => {
c.titres = [titre(1, 1)];
c.reservations = [reservation(1, 2, 1)];
});
assert.deepEqual(configurationDe(charge), configurationDepuisCharge(charge));
});
test('la charge reste intacte, et deux configurations ne partagent aucune liste de tours', () => {
const charge = chargeDe((c) => {
c.titres = [titre(1, 1)];
c.reservations = [reservation(1, 1, 1)];
});
const avant = structuredClone(charge);
const premiere = configurationDe(charge);
premiere.participants[0].anime.push(9);
assert.deepEqual(configurationDe(charge).participants[0].anime, [1, 2, 3]);
assert.deepEqual(charge, avant);
});
test('les quatre démonstrations : la configuration du stockage, plus anime — 33 personnes à [1, 2, 3, 4] dans la grande et sa variante, aucune dans les petites', () => {
const animateursAttendus = { grande: 33, 'grande-sans-exception': 33, petite: 0, 'petite-conflit': 0 };
assert.deepEqual(LIVREES.map(({ cle }) => cle).sort(), Object.keys(animateursAttendus).sort());
for (const { cle, texte } of LIVREES) {
const { charge } = analyser(texte);
const configuration = configurationDe(charge);
const stockage = configurationDepuisCharge(charge);
assert.deepEqual(Object.keys(configuration), ['participants', 'tables', 'tours', 'reservations', 'contraintes'], cle);
const animateurs = configuration.participants.filter((p) => Object.hasOwn(p, 'anime'));
assert.equal(animateurs.length, animateursAttendus[cle], cle);
assert.ok(animateurs.every(({ anime }) => JSON.stringify(anime) === '[1,2,3,4]'), cle);
assert.deepEqual(animateurs.map(({ id }) => id), stockage.reservations.map(({ participant }) => participant).sort((a, b) => a - b), cle);
const sansAnime = { ...configuration, participants: configuration.participants.map(({ anime, ...reste }) => reste) };
assert.deepEqual(sansAnime, stockage, cle);
}
});
});
// Les modules de src/application, hors épreuves, qui nomment
// configurationDepuisCharge, sauf configuration.js : chaque source est
// {chemin, texte}, chemin relatif à src/application.
function appelantsHorsDuPont(sources) {
return sources
.filter(({ chemin, texte }) => chemin !== 'configuration.js' && /\bconfigurationDepuisCharge\b/.test(sourceSansCommentaires(texte)))
.map(({ chemin }) => chemin);
}
// Le texte de la source chemin parmi sources.
const sourceTexte = (chemin, sources) => sources.find((source) => source.chemin === chemin).texte;
// Le texte sans ses commentaires de ligne ni de bloc : un commentaire peut
// nommer la fonction sans l'appeler.
const sourceSansCommentaires = (texte) => texte.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
describe('garde : configuration.js est le seul appelant de configurationDepuisCharge sous src/application', () => {
test('aucun autre module, hors épreuves, n’appelle ni n’importe configurationDepuisCharge', () => {
const racine = fileURLToPath(new URL('.', import.meta.url));
const sources = readdirSync(racine, { recursive: true })
.filter((nom) => nom.endsWith('.js') && !nom.endsWith('.test.js'))
.sort()
.map((chemin) => ({ chemin, texte: readFileSync(join(racine, chemin), 'utf8') }));
assert.ok(sources.length > 20, `${sources.length} sources seulement`);
assert.ok(/\bconfigurationDepuisCharge\(/.test(sourceTexte('configuration.js', sources)), 'configuration.js l’appelle');
assert.deepEqual(appelantsHorsDuPont(sources), []);
});
test('montrée rouge : un module inventé qui l’importe et l’appelle est relevé ; un commentaire qui la nomme ne l’est pas', () => {
const sources = [
{ chemin: 'configuration.js', texte: 'export const configurationDe = (c) => configurationDepuisCharge(c);\n' },
{ chemin: 'sous/invente.js', texte: "import { configurationDepuisCharge } from '../../stockage/document.js';\nexport const k = (c) => configurationDepuisCharge(c);\n" },
{ chemin: 'commente.js', texte: '// sur configurationDepuisCharge, par configurationDe\n/* configurationDepuisCharge */\nexport const u = "https://exemple.invalid";\n' },
];
assert.deepEqual(appelantsHorsDuPont(sources), ['sous/invente.js']);
});
});

View file

@ -2,17 +2,19 @@
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// 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 de
// src/csv dont un écran a besoin, telles quelles, 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.
// 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
// 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.
import { exporterRefus as exporterRefusCsv } from '../csv/import.js';
import { libelle } from './libelles.js';
export { apercevoir } from '../csv/apercu.js';
export { CHAMPS } from '../csv/colonnes.js';
export { decoder } from '../csv/encodage.js';
export { exporterParticipants } from '../csv/export.js';
export { JOINTURE_ETIQUETTES, exporterParticipants } from '../csv/export.js';
export { bilanRemplacement } from '../csv/import.js';
export { cleNormalisee } from '../csv/normalisation.js';

View file

@ -3,8 +3,8 @@
// É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 noms sont
// inventés.
// 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.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import * as apercu from '../csv/apercu.js';
@ -18,7 +18,7 @@ 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 et CHAMPS sont ceux de src/csv', () => {
test('decoder, apercevoir, exporterParticipants, bilanRemplacement, cleNormalisee, CHAMPS et JOINTURE_ETIQUETTES sont ceux de src/csv', () => {
const attendus = [
['decoder', encodage.decoder],
['apercevoir', apercu.apercevoir],
@ -32,8 +32,11 @@ describe('csv : le relais de src/csv pour l’interface (§ 13.4)', () => {
assert.equal(relais[nom], fonction, nom);
}
assert.equal(relais.CHAMPS, colonnes.CHAMPS);
assert.equal(relais.JOINTURE_ETIQUETTES, exportCsv.JOINTURE_ETIQUETTES);
assert.equal(relais.JOINTURE_ETIQUETTES, ', ');
assert.deepEqual(Object.keys(relais).sort(), [
'CHAMPS', 'apercevoir', 'bilanRemplacement', 'cleNormalisee', 'decoder', 'exporterParticipants', 'exporterRefus',
'CHAMPS', 'JOINTURE_ETIQUETTES', 'apercevoir', 'bilanRemplacement', 'cleNormalisee', 'decoder',
'exporterParticipants', 'exporterRefus',
]);
});
@ -64,3 +67,25 @@ describe('csv : le relais de src/csv pour l’interface (§ 13.4)', () => {
assert.ok(brut.length > 0);
});
});
describe('csv : les textes des refus d’une ligne pour ses étiquettes (§ 10.1, A17)', () => {
test('ETIQUETTE_MAL_FORMEE cite la valeur et dit la raison : 40 caractères au plus, ou un caractère de contrôle ou invisible', () => {
const longue = libelle('ETIQUETTE_MAL_FORMEE', { ligne: 3, valeur: 'x'.repeat(41), raison: 'longueur' });
const invisible = libelle('ETIQUETTE_MAL_FORMEE', { ligne: 3, valeur: 'fin\tance', raison: 'controle' });
assert.match(longue, new RegExp(`« ${'x'.repeat(41)} »`));
assert.match(longue, /40/);
assert.match(invisible, /« fin\tance »/);
assert.match(invisible, /contrôle/);
assert.notEqual(longue.replace('x'.repeat(41), ''), invisible.replace('fin\tance', ''));
});
test('ETIQUETTES_TROP_NOMBREUSES dit le nombre lu et la borne de 20', () => {
const texte = libelle('ETIQUETTES_TROP_NOMBREUSES', { ligne: 3, valeur: null, nombre: 23 });
assert.match(texte, /23/);
assert.match(texte, /20/);
});
test('une raison inconnue lève RangeError plutôt que d’écrire un motif faux', () => {
assert.throws(() => libelle('ETIQUETTE_MAL_FORMEE', { ligne: 3, valeur: 'x', raison: 'autre' }), RangeError);
});
});

View file

@ -3,7 +3,7 @@
// Le diagnostic de la configuration d'une charge (§ 5.6), pour l'interface,
// qui n'importe pas le moteur (§ 13.4). Le calcul est celui de diagnostiquer,
// sur configurationDepuisCharge : ce module n'en refait aucun chiffre.
// sur configurationDe : ce module n'en refait aucun chiffre.
//
// Une charge ouverte peut ne pas se normaliser — une table surréservée, que
// le stockage admet et que le moteur refuse (CONFIGURATION_INCOHERENTE, A2).
@ -12,7 +12,7 @@
// autre erreur est une faute du code et remonte.
import { diagnostiquer } from '../moteur/diagnostic.js';
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { configurationDepuisCharge } from '../stockage/document.js';
import { configurationDe } from './configuration.js';
/**
* Le diagnostic de la configuration de la charge (§ 5.6). Ne modifie pas la
@ -26,7 +26,7 @@ import { configurationDepuisCharge } from '../stockage/document.js';
*/
export function diagnostiquerCharge(charge) {
try {
return { diagnostic: diagnostiquer(configurationDepuisCharge(charge)), erreur: null };
return { diagnostic: diagnostiquer(configurationDe(charge)), erreur: null };
} catch (erreur) {
if (!(erreur instanceof ErreurConfiguration)) throw erreur;
return { diagnostic: null, erreur: { code: erreur.code, details: erreur.details } };

View file

@ -12,8 +12,9 @@ import { diagnostiquer } from '../moteur/diagnostic.js';
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { normaliser } from '../moteur/configuration.js';
import { serialiser } from '../stockage/canonique.js';
import { analyser, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
import { analyser, creerCharge } from '../stockage/document.js';
import { VERSION } from '../version.genere.js';
import { configurationDe } from './configuration.js';
import { diagnostiquerCharge } from './diagnostic.js';
const chargeDemo = (cle) => analyser(texteLivre(cle)).charge;
@ -30,7 +31,7 @@ describe('diagnostiquerCharge', () => {
test('petite démonstration : le diagnostic de sa configuration, erreur null', () => {
const charge = chargeDemo('petite');
const rendu = diagnostiquerCharge(charge);
assert.deepEqual(rendu, { diagnostic: diagnostiquer(configurationDepuisCharge(charge)), erreur: null });
assert.deepEqual(rendu, { diagnostic: diagnostiquer(configurationDe(charge)), erreur: null });
assert.ok(rendu.diagnostic.plafondAPriori.repartition.length > 0);
});
@ -39,7 +40,7 @@ describe('diagnostiquerCharge', () => {
const { diagnostic, erreur } = diagnostiquerCharge(charge);
assert.equal(erreur, null);
assert.equal(diagnostic.collisions.length, 1);
assert.deepEqual(diagnostic, diagnostiquer(configurationDepuisCharge(charge)));
assert.deepEqual(diagnostic, diagnostiquer(configurationDe(charge)));
});
test('quatre réservées sur une table de 3 : analyser admet, normaliser refuse ; le code rendu, sans lever', () => {
@ -49,7 +50,7 @@ describe('diagnostiquerCharge', () => {
assert.equal(relue.reservations.length, 4);
let attendue = null;
try {
normaliser(configurationDepuisCharge(relue));
normaliser(configurationDe(relue));
} catch (erreur) {
assert.ok(erreur instanceof ErreurConfiguration);
attendue = erreur;
@ -64,7 +65,7 @@ describe('diagnostiquerCharge', () => {
const charge = creerCharge({ id: 'evt-neuf', nom: 'Club des Merles', siegesParDefaut: 8, tours: 4 });
const { diagnostic, erreur } = diagnostiquerCharge(charge);
assert.equal(erreur, null);
assert.deepEqual(diagnostic, diagnostiquer(configurationDepuisCharge(charge)));
assert.deepEqual(diagnostic, diagnostiquer(configurationDe(charge)));
});
test('une erreur qui n\u{2019}est pas une ErreurConfiguration remonte', () => {

View file

@ -37,8 +37,9 @@ import { ecartsAuPlafondAPriori, minimumAtteint, troisChiffres } from '../moteur
import { plafondsRealises } from '../moteur/plafond.js';
import { verifierIndicateurs, verifierInvariants } from '../moteur/verification.js';
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { CIBLE_REDONDANCE_PAR_DEFAUT, cibleRedondance, configurationDepuisCharge } from '../stockage/document.js';
import { CIBLE_REDONDANCE_PAR_DEFAUT, cibleRedondance } from '../stockage/document.js';
import { derive, deriveSansRevalider, fautes, planDepuisFichier, planSansRevalider } from '../stockage/placements.js';
import { configurationDe } from './configuration.js';
export { CIBLE_REDONDANCE_PAR_DEFAUT, cibleRedondance };
@ -115,7 +116,7 @@ export function evaluer(charge, { plafondsAPriori = 'calcul' } = {}) {
if (plafondsAPriori !== 'calcul' && plafondsAPriori !== null) {
throw new TypeError(`evaluer : plafondsAPriori 'calcul' ou null attendu, reçu ${JSON.stringify(plafondsAPriori)}`);
}
const configuration = configurationDepuisCharge(charge);
const configuration = configurationDe(charge);
const diagnostic = diagnostiquer(configuration);
const instance = normaliser(configuration);
const aPriori = plafondsAPriori === 'calcul' ? diagnostic.plafondAPriori.parPersonne : null;
@ -178,7 +179,7 @@ export function resumeDuRetenu(charge) {
if (retenu === null || !seCompare(retenu, charge)) return null;
let instance;
try {
instance = normaliser(configurationDepuisCharge(charge));
instance = normaliser(configurationDe(charge));
} catch (erreur) {
if (erreur instanceof ErreurConfiguration) return null;
throw erreur;

View file

@ -19,10 +19,11 @@ import { mesurer } from '../moteur/indicateurs.js';
import { ecartsAuPlafondAPriori } from '../moteur/manque.js';
import { plafondsAPriori } from '../moteur/plafond.js';
import { rechercher } from '../moteur/recherche.js';
import { analyser, capacite, configurationDepuisCharge } from '../stockage/document.js';
import { analyser, capacite } from '../stockage/document.js';
import { fautes, planDepuisFichier } from '../stockage/placements.js';
import { VERSION } from '../version.genere.js';
import { appliquerCommande } from './commandes.js';
import { configurationDe } from './configuration.js';
import {
CIBLE_REDONDANCE_PAR_DEFAUT,
cibleRedondance,
@ -38,7 +39,7 @@ const chargeDemo = (cle) => analyser(texteLivre(cle)).charge;
// La charge, une génération du moteur de plus, enregistrée par la commande.
function generee(charge, reglages) {
const configuration = configurationDepuisCharge(charge);
const configuration = configurationDe(charge);
const propositions = rechercher(configuration, reglages);
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
}
@ -139,7 +140,7 @@ describe("évaluation : la mesure qui fait foi (§ 5.10, § 13.2)", () => {
test('chaque ligne comparable porte mesurer, les plafonds, les trois chiffres ; le classement est celui de classer', () => {
const charge = figer(generee(chargeDemo('petite-conflit'), { graine: 5, nombre: 3, arret: 400 }));
const table = evaluer(charge);
const instance = normaliser(configurationDepuisCharge(charge));
const instance = normaliser(configurationDe(charge));
const aPriori = plafondsAPriori(instance);
assert.deepEqual(table.population, instance.ids);
assert.equal(table.propositions.length, 3);
@ -179,7 +180,7 @@ describe("évaluation : la mesure qui fait foi (§ 5.10, § 13.2)", () => {
assert.deepEqual(table.classement.ordre, [3, 2, 1, 4]);
assert.deepEqual(table.classement.criteresAppliques[0], 'ecartAuPlafondAPrioriMax');
for (const ligne of table.propositions) {
const ecarts = ecartsAuPlafondAPriori(ligne.mesures, plafondsAPriori(normaliser(configurationDepuisCharge(charge))));
const ecarts = ecartsAuPlafondAPriori(ligne.mesures, plafondsAPriori(normaliser(configurationDe(charge))));
assert.equal(ligne.ecartAuPlafondAPrioriMax, Math.max(...ecarts));
}
});
@ -228,7 +229,7 @@ describe('évaluation : les formes closes ne corrigent rien (§ 5.4)', () => {
charge.prochainsIds.proposition = 2;
charge.evenement.etat = 'retenu';
const table = evaluer(figer(charge));
const plancher = diagnostiquer(configurationDepuisCharge(charge)).collisions.reduce((total, { plancherCumulees }) => total + plancherCumulees, 0);
const plancher = diagnostiquer(configurationDe(charge)).collisions.reduce((total, { plancherCumulees }) => total + plancherCumulees, 0);
assert.equal(plancher, 4);
assert.equal(table.retenu.statut, 'comparable');
assert.equal(table.retenu.id, 1);
@ -386,7 +387,7 @@ describe('évaluation : les imposés (§ 5.4, § 15.1)', () => {
test('la grande démonstration : 99 retours imposés, 33 ancrages qui en imposent, ceux de diagnostiquer', () => {
const charge = chargeDemo('grande');
const table = evaluer(figer(charge));
const diagnostic = diagnostiquer(configurationDepuisCharge(charge));
const diagnostic = diagnostiquer(configurationDe(charge));
assert.deepEqual(table.imposes, { retours: 99, ancrages: 33 });
assert.deepEqual(table.imposes, { retours: diagnostic.retoursImposes, ancrages: diagnostic.ancragesImposantRetours });
});
@ -454,7 +455,7 @@ describe('resumeDuRetenu : la différence à la cible que lit la liste des évé
const { mesures } = evaluer(charge).retenu;
assert.ok(mesures.totalRedondance !== 1000, 'la différence se distingue de zéro');
assert.deepEqual(resume, { diffRedondance: Math.abs(1000 - mesures.totalRedondance) });
const instance = normaliser(configurationDepuisCharge(charge));
const instance = normaliser(configurationDe(charge));
const attendues = mesurer(instance, indexerPlan(instance, planDepuisFichier(charge.retenu)));
assert.equal(attendues.totalRedondance, mesures.totalRedondance);
assert.equal(CIBLE_REDONDANCE_PAR_DEFAUT, 1000);

View file

@ -44,9 +44,10 @@ import { normaliser, nombrePlacesManquantes } from '../moteur/configuration.js';
import { ErreurAnnulee, ErreurConfiguration } from '../moteur/erreurs.js';
import { rechercher, verifierReglages } from '../moteur/recherche.js';
import { verifierInvariants } from '../moteur/verification.js';
import { GENERATION_PAR_DEFAUT, configurationDepuisCharge } from '../stockage/document.js';
import { GENERATION_PAR_DEFAUT } from '../stockage/document.js';
import { derive, planDepuisFichier, versFichier } from '../stockage/placements.js';
import { lireEntier } from './capacite.js';
import { configurationDe } from './configuration.js';
// src/interface reçoit l'annulation d'ici : elle ne joint pas le moteur.
export { ErreurAnnulee } from '../moteur/erreurs.js';
@ -363,7 +364,7 @@ export function creerControleurGeneration({ seance, executeur, horloge, produitV
throw new ErreurGeneration('AUCUN_PARTICIPANT', { remede: versSection('participants') });
}
if (charge.tables.length === 0) throw new ErreurGeneration('AUCUNE_TABLE', { remede: versSection('tables') });
const configuration = configurationDepuisCharge(charge);
const configuration = configurationDe(charge);
let instance;
try {
instance = normaliser(configuration);

View file

@ -19,11 +19,12 @@ import { rechercher, regenerer } from '../moteur/recherche.js';
import { verifierInvariants } from '../moteur/verification.js';
import { serialiser } from '../stockage/canonique.js';
import { creer as creerDansLeDepot } from '../stockage/depot.js';
import { FORMAT, analyser, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
import { FORMAT, analyser, creerCharge } from '../stockage/document.js';
import { lireJournal } from '../stockage/journal.js';
import { SUFFIXES } from '../stockage/noms.js';
import { derive, planDepuisFichier, versFichier } from '../stockage/placements.js';
import { VERSION } from '../version.genere.js';
import { configurationDe } from './configuration.js';
import { ErreurCommande } from './erreurs.js';
import { creerExecuteurTravailleur } from './executeur_travailleur.js';
import {
@ -322,7 +323,7 @@ describe('génération : la saisie des réglages (§ 5.7)', () => {
describe("génération : l'exécuteur direct refuse un réglage comme le travailleur", () => {
test('un réglage hors de son domaine rejette REGLAGE_HORS_DOMAINE, qui le nomme, sans le message du moteur', async () => {
const { charge } = analyser(texteLivre('petite'));
const configuration = configurationDepuisCharge(charge);
const configuration = configurationDe(charge);
const { resultat } = creerExecuteurDirect().lancer(configuration, { graine: 2 ** 32, nombre: 1, arret: 10 });
await refuse(resultat, 'REGLAGE_HORS_DOMAINE', { reglage: 'graine', remede: null });
});
@ -398,7 +399,7 @@ describe("génération : les refus d'avant lancement (§ 2.1, § 5.9, § 8.4, §
const sansTable = await monter({ charges: [chargeDe({ n: 3, tables: 0 })] });
await refuseSansRien(sansTable, 'AUCUNE_TABLE', { remede: VERS_TABLES });
// Le moteur accepte N = 0 et rend des plans vides : le refus est celui du contrôleur.
const vide = configurationDepuisCharge(chargeDe({ n: 3, exclus: [1, 2, 3], tables: 2 }));
const vide = configurationDe(chargeDe({ n: 3, exclus: [1, 2, 3], tables: 2 }));
assert.deepEqual(rechercher(vide, COURT)[0].plan.tours, [[[], []], [[], []]]);
});
@ -413,7 +414,7 @@ describe("génération : les refus d'avant lancement (§ 2.1, § 5.9, § 8.4, §
const montage = await monter({ charges: [charge] });
let attendu = null;
try {
normaliser(configurationDepuisCharge(charge));
normaliser(configurationDe(charge));
} catch (erreur) {
attendu = erreur;
}
@ -535,7 +536,7 @@ describe('génération : un compte, jamais une durée (§ 5.10, § 14.7)', () =>
assert.equal(avancements, 10);
assert.deepEqual(bilan.ajoutees, [1, 2]);
assert.equal(bilan.dureeMs, 10 * pas);
const configuration = configurationDepuisCharge(montage.charge());
const configuration = configurationDe(montage.charge());
const attendues = rechercher(configuration, reglages);
assert.equal(attendues.length, 2);
montage.charge().propositions.forEach((proposition, rang) => {
@ -640,7 +641,7 @@ describe("génération : les champs d'une proposition (§ 5.7, § 8.9)", () => {
const reglages = { graine: 41, nombre: 3, arret: 300, historique: 50 };
await montage.controleur.generer(reglages);
const charge = montage.charge();
const configuration = configurationDepuisCharge(charge);
const configuration = configurationDe(charge);
const moteur = rechercher(configuration, reglages);
assert.equal(charge.propositions.length, 3);
charge.propositions.forEach((proposition, rang) => {
@ -762,7 +763,7 @@ describe('génération : les invariants (§ 14.12)', () => {
const bilan = await montage.controleur.generer({ graine: 3, nombre: 2, arret: 20000 });
assert.deepEqual(bilan.ajoutees, [1, 2]);
const charge = montage.charge();
const instance = normaliser(configurationDepuisCharge(charge));
const instance = normaliser(configurationDe(charge));
assert.ok(instance.k > 0, 'aucun ancré');
for (const proposition of charge.propositions) assert.deepEqual(verifierInvariants(instance, planDepuisFichier(proposition)), []);
});
@ -781,7 +782,7 @@ describe('génération : compléter un placement partiel (§ 5.8)', () => {
const montage = await monter({ charges: [charge] });
await montage.controleur.generer({ graine: 8, nombre: 3, arret: 2000 });
const apres = montage.charge();
const instance = normaliser(configurationDepuisCharge(apres));
const instance = normaliser(configurationDe(apres));
assert.ok(apres.propositions.length > 0);
for (const proposition of apres.propositions) {
const plan = planDepuisFichier(proposition);
@ -909,7 +910,7 @@ describe("génération : l'annulation (§ 5.10, § 8.4)", () => {
// --- L'exécuteur direct (§ 5.10) -----------------------------------------------------
describe("génération : l'exécuteur direct (§ 5.10)", () => {
const configuration = configurationDepuisCharge(chargeDemo('petite-conflit'));
const configuration = configurationDe(chargeDemo('petite-conflit'));
test('annuler aussitôt après lancer : ErreurAnnulee, aucun avancement', async () => {
const execution = creerExecuteurDirect().lancer(configuration, { graine: 1, nombre: 2, arret: 5000 });
@ -961,7 +962,7 @@ describe("génération : l'exécuteur direct (§ 5.10)", () => {
});
test('des places manquantes : ErreurGeneration PLACES_MANQUANTES, code et détails du moteur', async () => {
const manque = configurationDepuisCharge(chargeDe({ n: 13, tables: 4, sieges: 3 }));
const manque = configurationDe(chargeDe({ n: 13, tables: 4, sieges: 3 }));
const leve = await rejet(creerExecuteurDirect().lancer(manque, COURT).resultat);
assert.ok(leve instanceof ErreurGeneration);
assert.deepEqual([leve.code, leve.details], ['PLACES_MANQUANTES', { placesManquantes: 1 }]);
@ -1233,12 +1234,12 @@ describe('génération : les cœurs de calcul, un réglage du poste pour la séa
const bilan = await montage.controleur.generer(reglages);
assert.deepEqual(bilan.ajoutees, [1, 2, 3, 4, 5]);
assert.deepEqual(montage.espion.lancements.at(-1).options, { coeurs });
const attendues = rechercher(configurationDepuisCharge(montage.charge()), reglages);
const attendues = rechercher(configurationDe(montage.charge()), reglages);
assert.deepEqual(
montage.charge().propositions.map(({ graine }) => graine),
attendues.map(({ graine }) => graine),
);
assert.deepEqual(montage.charge().propositions.map(planDepuisFichier), attendues.map((p) => planRange(p, configurationDepuisCharge(montage.charge()))));
assert.deepEqual(montage.charge().propositions.map(planDepuisFichier), attendues.map((p) => planRange(p, configurationDe(montage.charge()))));
assert.equal(montage.controleur.derniereGeneration().coeurs, coeurs);
etats.push(montage.etatDisque());
}
@ -1334,7 +1335,7 @@ describe('génération : les cœurs de calcul, un réglage du poste pour la séa
assert.equal(creerExecuteurDirect().coeursDisponibles, 1);
assert.equal(creerExecuteurDirect({ coeursDisponibles: 12 }).coeursDisponibles, 12);
assert.equal(creerExecuteurDirect({ coeursDisponibles: 0 }).coeursDisponibles, 1);
const configuration = configurationDepuisCharge(chargeDemo('petite-conflit'));
const configuration = configurationDe(chargeDemo('petite-conflit'));
const reglages = { graine: 4, nombre: 3, arret: 2000 };
const execution = creerExecuteurDirect({ coeursDisponibles: 4 }).lancer(configuration, reglages, { coeurs: 3 });
assert.equal(execution.coeurs, 1);

View file

@ -339,6 +339,8 @@ const EXEMPLES = new Map([
['CHAMPS_EN_TROP', { ligne: 4, valeur: null }],
['NOM_ABSENT', { ligne: 5, valeur: null }],
['EXCLU_INCONNU', { ligne: 6, valeur: 'peut-être' }],
['ETIQUETTE_MAL_FORMEE', { ligne: 6, valeur: 'ressources\thumaines', raison: 'controle' }],
['ETIQUETTES_TROP_NOMBREUSES', { ligne: 6, valeur: null, nombre: 21 }],
['PLUSIEURS_CORRESPONDENT', { ligne: 7, valeur: null, participants: [3, 8] }],
['DEJA_DESIGNE', { ligne: 9, valeur: null, participants: [3], premiereLigne: 4 }],
['PLAN_BLOQUE', { remede: { geste: 'debloquer' } }],
@ -412,6 +414,16 @@ const EXEMPLES = new Map([
['ORDRE_PLAFONDS', { index: 2, participant: 3 }],
['PLAFOND_REALISE', { index: 0, participant: 1 }],
['DEPASSEMENT', { index: 4, participant: 9 }],
['OBJECTIF_INCONNU', { table: 9 }],
['OBJECTIF_TOUR', { table: 2, tour: 5 }],
['OBJECTIF_CONFLIT', { table: 2, tour: null }],
['PRIORITES', { faute: 'seulASonPalier', critere: 'toursHorsObjectif', palier: 2 }],
['ANIMATION_NON_FIXEE', { participant: 4, tour: 3 }],
['TOURS_SERVIS', { index: 2, participant: 3 }],
['TOURS_SERVIS_NON_ASSIS', { index: 2, participant: 3 }],
['ANIMATEURS_RENCONTRES', { index: 4, participant: 9 }],
['JAMAIS_SERVIES', {}],
['HORS_OBJECTIF_MAX', {}],
// Génération
['GENERATION_EN_COURS', { remede: null }],
['TRAVAILLEUR_EN_ECHEC', { remede: null }],
@ -455,6 +467,9 @@ const EXEMPLES = new Map([
['RESERVATIONS_RETABLIES', { participant: 1, personne: 'Iris Ombrelle', reservations: 2, titres: 1 }],
['RESERVATIONS_RETIREES', { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 }],
['IMPORT_EN_RETENU', {}],
['ETIQUETTE_INVALIDE', { valeur: 'ressources\u{0009}humaines', raison: 'controle', remede: null }],
['ETIQUETTES_EN_TROP', { nombre: 21, remede: null }],
['ORTHOGRAPHE_RETENUE', { saisie: 'Finance', retenue: 'finance' }],
// Façade des événements
['QUESTION_EN_ATTENTE', { question: 'PORTABLE_NON_INSCRIPTIBLE', remede: null }],
['EXPLORATEUR_ABSENT', { remede: null }],
@ -472,6 +487,7 @@ const EXEMPLES = new Map([
['ENTREE_EXCLURE_PARTICIPANT', { personne: 'Iris Ombrelle' }],
['ENTREE_REINTEGRER_PARTICIPANT', { personne: 'Iris Ombrelle' }],
['ENTREE_SUPPRIMER_PARTICIPANT', { personne: 'Iris Ombrelle' }],
['ENTREE_CHANGER_ETIQUETTES', { personne: 'Iris Ombrelle', ajoutees: ['Montréal', 'finance'], retirees: ['bénévole'] }],
['ENTREE_IMPORTER_PARTICIPANTS', { mode: 'ajouter', ajoutes: 12, misAJour: 0, retires: 0 }],
['ENTREE_CHANGER_ETAT', { etat: 'bloque' }],
['ENTREE_DEBLOQUER', { etat: 'retenu' }],
@ -541,6 +557,15 @@ const EXEMPLES = new Map([
['ENTREE_CHANGER_TOURS', { tours: 3, retirees: 2 }],
['ENTREE_MODIFIER_REGLAGE', { cle: 'separerAppartenances', valeur: false }],
['ENTREE_CHANGER_CIBLE_REDONDANCE', { cible: 1200 }],
// Objectifs des tables
['OBJECTIF_HORS_SOIREE', { tour: 5, remede: null }],
['OBJECTIF_SANS_ETIQUETTE', { table: 2, tour: 3, remede: { commande: 'retirerObjectif', arguments: { table: 2, tour: 3 } } }],
['TABLE_DISPARUE', { table: 9, remede: null }],
['OBJECTIF_SANS_CORRESPONDANT', { table: 2, tour: null }],
['ETIQUETTE_ORPHELINE', { etiquette: 'Finance', voisine: 'finance' }],
['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' }],
// Planche de badges
['PLANCHE_SANS_PLACEMENT', { remede: { geste: 'ouvrirSection', section: 'propositions' } }],
['PROPOSITION_FAUTIVE', { id: 2, remede: { geste: 'ouvrirSection', section: 'propositions' } }],
@ -1045,3 +1070,62 @@ describe('libellés : les codes du moteur et de la génération (§ 14.6, § 5.7
assert.match(libelle('HORS_DOMAINE', { min: 1, max: null, remede: null }), /au moins 1\b/);
});
});
describe('libellés : les étiquettes et les objectifs des tables (§ 4.5, § 5.9, § 8.2)', () => {
test('les libellés figés nomment la personne ou la table par son numéro, chaque étiquette citée, la règle en mots', () => {
assert.equal(
libelle('ENTREE_CHANGER_ETIQUETTES', { personne: 'Iris Ombrelle', ajoutees: ['finance'], retirees: [] }),
'Étiquettes de Iris Ombrelle : ajouté « finance »',
);
assert.equal(
libelle('ENTREE_POSER_OBJECTIF', { numero: 7, tour: null, regle: null, etiquettes: ['finance'] }),
'Table 7 : objectif de la soirée « finance », règle par défaut',
);
assert.equal(
libelle('ENTREE_POSER_OBJECTIF', { numero: 7, tour: 2, regle: 'ou', etiquettes: ['Montréal', 'finance', 'voile'] }),
'Table 7, tour 2 : objectif « Montréal », « finance » et « voile », au moins une étiquette',
);
assert.equal(libelle('ENTREE_RETIRER_OBJECTIF', { numero: 7, tour: 2 }), 'Table 7, tour 2 : objectif retiré');
assert.equal(libelle('ENTREE_CHANGER_REGLE_OBJECTIFS', { regle: 'ou' }), 'Règle par défaut des objectifs : au moins une étiquette');
assert.equal(libelle('ENTREE_CHANGER_TOURS', { tours: 3, retirees: 0, objectifsRetires: 1 }), 'Nombre de tours : 3 ; 1 objectif retiré');
assert.equal(libelle('ENTREE_CHANGER_TOURS', { tours: 3, retirees: 2 }), 'Nombre de tours : 3 ; 2 réservations retirées');
assert.equal(
libelle('ENTREE_MODIFIER_REGLAGE', { cle: 'respecterObjectifs', valeur: false }),
'Respecter les objectifs des tables : désactivé',
);
assert.throws(() => libelle('ENTREE_CHANGER_REGLE_OBJECTIFS', { regle: 'peut-être' }), RangeError);
});
test('une étiquette refusée dit sa cause et la forme attendue ; une raison inconnue lève', () => {
// ORTHOGRAPHE_RETENUE ne naît que d'une orthographe que l'événement
// porte déjà : son texte le dit.
assert.equal(
libelle('ORTHOGRAPHE_RETENUE', { saisie: 'FINANCE', retenue: 'finance' }),
"« FINANCE » est enregistrée sous l'orthographe que l'événement porte déjà : « finance ».",
);
const vide = libelle('ETIQUETTE_INVALIDE', { valeur: '', raison: 'vide', remede: null });
assert.match(vide, /aucune étiquette/);
assert.match(vide, /de 1 à 40 caractères/);
assert.match(libelle('ETIQUETTE_INVALIDE', { valeur: 'x'.repeat(41), raison: 'longueur', remede: null }), /dépasse 40 caractères/);
assert.match(libelle('ETIQUETTES_EN_TROP', { nombre: 21, remede: null }), /au plus 20 étiquettes ; celles-ci en feraient 21/);
assert.throws(() => libelle('ETIQUETTE_INVALIDE', { valeur: 'x', raison: 'inventee', remede: null }), RangeError);
});
test("les avis d'un objectif disent son tour ; l'étiquette orpheline nomme sa voisine quand il y en a une", () => {
assert.match(libelle('OBJECTIF_SANS_CORRESPONDANT', { table: 2, tour: 3 }), /au tour 3/);
assert.match(libelle('OBJECTIF_SANS_CORRESPONDANT', { table: 2, tour: null }), /pour la soirée/);
assert.equal(
libelle('ETIQUETTE_ORPHELINE', { etiquette: 'Finance', voisine: null }),
"Aucune personne ne porte l'étiquette « Finance ».",
);
assert.match(libelle('ETIQUETTE_ORPHELINE', { etiquette: 'Finance', voisine: 'finance' }), /une personne porte « finance »/);
// Le remède « Retirer l'objectif » ne se dit que sur un couple qui porte
// un objectif ; sans lui, le refus nomme sa cause seule (§ 5.9).
const retirer = { commande: 'retirerObjectif', arguments: { table: 2, tour: 3 } };
assert.match(libelle('OBJECTIF_SANS_ETIQUETTE', { table: 2, tour: 3, remede: retirer }), /« Retirer l'objectif »/);
assert.equal(
libelle('OBJECTIF_SANS_ETIQUETTE', { table: 2, tour: 3, remede: null }),
'Un objectif au tour 3 vise au moins une étiquette.',
);
});
});

View file

@ -3,12 +3,20 @@
// 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.
import { cite, enumeration } from './aides.js';
import { ETIQUETTE_LONGUEUR_MAX, ETIQUETTES_MAX } from '../../stockage/etiquettes.js';
import { cite, 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.';
const refusEncodage = (constat) => () => `${constat} ${REMEDE_UTF8}`;
// Ce qui rend fautive une étiquette, par la raison que rend lireEtiquettes
// (§ 4.5).
const FAUTES_D_ETIQUETTE = new Map([
['longueur', `dépasse ${ETIQUETTE_LONGUEUR_MAX} caractères`],
['controle', 'porte un caractère de contrôle ou invisible, tabulation comprise'],
]);
export const TABLE = new Map([
// --- Le CSV (§ 10.1) -------------------------------------------------------
['UTF16_SANS_MARQUE', refusEncodage("Le fichier semble encodé en UTF-16, sans la marque qui l'annonce.")],
@ -43,6 +51,16 @@ export const TABLE = new Map([
`La valeur d'« exclu » ${cite(valeur)} n'est pas reconnue : ` +
'oui, o, vrai, 1 ou x pour exclure ; non, n, faux, 0 ou vide sinon.',
],
[
'ETIQUETTE_MAL_FORMEE',
({ valeur, raison }) =>
`L'étiquette ${cite(valeur)} ${nomDans(FAUTES_D_ETIQUETTE, raison, 'raison')} : ` +
'corrigez-la, puis importez la ligne de nouveau.',
],
[
'ETIQUETTES_TROP_NOMBREUSES',
({ nombre }) => `La ligne porte ${nombre} étiquettes : une personne en porte ${ETIQUETTES_MAX} au plus.`,
],
[
'PLUSIEURS_CORRESPONDENT',
({ participants }) =>

View file

@ -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 commandes du mobilier — tables, sièges, tours, réglages
// (§ 4, § 6, § 7.1, § 8.2, § 14.6) : les refus d'une capacité et d'un nombre
// de tours, leurs avertissements, les causes d'un siège retiré et les
// libellés figés du journal. Les détails désignent tables et personnes par
// 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
// 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.
@ -21,8 +22,25 @@ const NOMS_DES_REGLAGES = new Map([
['generation.nombre', 'Propositions par génération'],
['generation.arret', 'Mouvements par proposition'],
['generation.historique', "Historique d'acceptation"],
['respecterObjectifs', 'Respecter les objectifs des tables'],
['rencontrerAnimateurs', "Rencontrer le plus d'animateurs différents"],
]);
// La règle d'un objectif (§ 4.5) : celle de l'événement quand elle est
// nulle.
const NOMS_DES_REGLES = new Map([
['ou', 'au moins une étiquette'],
['et', 'toutes les étiquettes'],
]);
const nomRegle = (regle) => nomDans(NOMS_DES_REGLES, regle, 'règle');
// Le lieu d'un objectif dans un libellé figé : sa table, et son tour quand
// il n'est pas de la soirée.
const lieuObjectif = (numero, tour) => (tour === null ? `Table ${numero}` : `Table ${numero}, tour ${tour}`);
// Ce qu'un avis ou un refus dit du tour de l'objectif qu'il désigne.
const aCeTour = (tour) => (tour === null ? 'pour la soirée' : `au tour ${tour}`);
// La valeur d'un réglage dans un libellé : activé ou désactivé pour un
// interrupteur, le nombre sinon.
const valeurDeReglage = (valeur) => (typeof valeur === 'boolean' ? (valeur ? 'activé' : 'désactivé') : String(valeur));
@ -91,14 +109,40 @@ export const TABLE = new Map([
`une liste de noms passe sur l'autre table. Déplacer la table ${numeroVoisine} la pose sur une case libre.`,
],
// --- Les objectifs des tables (§ 4.5, § 5.9) -------------------------------
['OBJECTIF_HORS_SOIREE', ({ tour }) => `La soirée n'a pas de tour ${tour} : un objectif vise la soirée ou l'un de ses tours.`],
// Le geste « Retirer l'objectif » ne se dit qu'avec son remède : un couple
// sans objectif n'a rien à retirer, et le refus nomme sa cause seule (§ 5.9).
[
'OBJECTIF_SANS_ETIQUETTE',
({ tour, remede }) =>
`Un objectif ${aCeTour(tour)} vise au moins une étiquette.` +
(remede === null || remede === undefined
? ''
: " Pour que la table n'attende plus personne, « Retirer l'objectif »."),
],
['TABLE_DISPARUE', ({ table }) => `La table d'identifiant ${table} n'est plus dans cet événement.`],
[
'OBJECTIF_SANS_CORRESPONDANT',
({ tour }) =>
`Aucune personne présente ne correspond à l'objectif ${aCeTour(tour)} : la table ne servira personne à ce titre.`,
],
[
'ETIQUETTE_ORPHELINE',
({ etiquette: motCle, voisine }) =>
`Aucune personne ne porte l'étiquette ${cite(motCle)}` +
(voisine === null || voisine === undefined ? '.' : ` ; une personne porte ${cite(voisine)}.`),
],
// --- Les libellés figés du journal (§ 8.2) ---------------------------------
['ENTREE_CREER_TABLE', ({ numero }) => `Ajouté la table ${numero}`],
[
'ENTREE_SUPPRIMER_TABLE',
({ numero, reservations, titres }) => {
({ numero, reservations, titres, objectifs = 0 }) => {
const retires = [];
if (reservations > 0) retires.push(compte(reservations, 'réservation', 'réservations'));
if (titres > 0) retires.push(compte(titres, 'titre', 'titres'));
if (objectifs > 0) retires.push(compte(objectifs, 'objectif', 'objectifs'));
return `Supprimé la table ${numero}${retires.length > 0 ? ` ; retirés : ${enumeration(retires)}` : ''}`;
},
],
@ -112,12 +156,27 @@ export const TABLE = new Map([
],
[
'ENTREE_CHANGER_TOURS',
({ tours, retirees }) =>
`Nombre de tours : ${tours}${retirees > 0 ? ` ; ${compte(retirees, 'réservation retirée', 'réservations retirées')}` : ''}`,
({ tours, retirees, objectifsRetires = 0 }) => {
const retires = [];
if (retirees > 0) retires.push(compte(retirees, 'réservation retirée', 'réservations retirées'));
if (objectifsRetires > 0) retires.push(compte(objectifsRetires, 'objectif retiré', 'objectifs retirés'));
return `Nombre de tours : ${tours}${retires.length > 0 ? ` ; ${retires.join(', ')}` : ''}`;
},
],
[
'ENTREE_MODIFIER_REGLAGE',
({ cle, valeur }) => `${nomDans(NOMS_DES_REGLAGES, cle, 'réglage')} : ${valeurDeReglage(valeur)}`,
],
['ENTREE_CHANGER_CIBLE_REDONDANCE', ({ cible }) => `Cible de la somme de redondance : ${cible}`],
[
'ENTREE_POSER_OBJECTIF',
({ numero, tour, regle, etiquettes }) =>
`${lieuObjectif(numero, tour)} : objectif${tour === null ? ' de la soirée' : ''} ` +
`${enumeration(etiquettes.map(cite))}, ${regle === null ? 'règle par défaut' : nomRegle(regle)}`,
],
[
'ENTREE_RETIRER_OBJECTIF',
({ 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)}`],
]);

View file

@ -5,6 +5,7 @@
// libellés de ses critères (§ 5, § 14.6) : les codes d'une
// ErreurConfiguration, ceux des violations de verifierInvariants et de
// verifierIndicateurs, et moteur.critere.<nom> pour chaque nom de CRITERES.
// 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
// distinguent par les détails qu'ils portent. Une réservation que le moteur
@ -30,6 +31,17 @@ const personneDe = ({ index, participant }) =>
// Un rang de réservation, compté à partir de 0 par le moteur.
const reservationDe = (rang) => `la réservation n° ${rang + 1}`;
// Les fautes d'une liste de priorités (verifierPriorites), par faute.
const FAUTES_DE_PRIORITES = new Map([
['forme', "sa forme n'est pas celle d'une liste de paliers"],
['critereInconnu', 'elle nomme un critère inconnu'],
['critereRepete', 'elle nomme deux fois un même critère'],
['critereAbsent', 'elle omet un critère'],
['palierVide', 'un palier est vide'],
['seulASonPalier', 'un critère qui se range seul partage son palier'],
['poids', 'un poids sort des entiers de 1 à 100'],
]);
export const TABLE = new Map([
// Une salle trop petite : avertissement d'un geste qui fait croître le
// manque (A2), refus de la génération (A6) ; un seul texte pour les deux.
@ -84,6 +96,31 @@ export const TABLE = new Map([
`La personne n° ${participant} est réservée au tour ${tour} à deux tables, ` +
`d'identifiants ${tables[0]} et ${tables[1]}.`,
],
[
'OBJECTIF_INCONNU',
({ table }) => `Un objectif désigne la table d'identifiant ${table}, que l'événement n'a pas.`,
],
[
'OBJECTIF_TOUR',
({ table, tour }) => `L'objectif de la table d'identifiant ${table} désigne le tour ${tour}, que l'événement n'a pas.`,
],
// OBJECTIF_CONFLIT {table, tour} : tour null pour deux objectifs de soirée.
[
'OBJECTIF_CONFLIT',
({ table, tour }) =>
`La table d'identifiant ${table} porte deux objectifs ${tour === null ? 'pour la soirée' : `pour le tour ${tour}`}.`,
],
[
'PRIORITES',
({ faute, palier }) =>
`L'ordre des priorités est refusé : ${nomDans(FAUTES_DE_PRIORITES, faute, 'faute de priorités')}` +
`${palier === undefined ? '' : ` (palier ${palier + 1})`}.`,
],
[
'ANIMATION_NON_FIXEE',
({ participant, tour }) =>
`La personne n° ${participant} anime au tour ${tour}, où aucune réservation ne la place à une table.`,
],
[
'ENUMERATION_TROP_GRANDE',
({ participant, limite }) =>
@ -144,6 +181,23 @@ export const TABLE = new Map([
'DEPASSEMENT',
(details) => `Les rencontres de ${personneDe(details)} dépassent son plafond réalisé : le calcul est faux.`,
],
[
'TOURS_SERVIS',
(details) => `Les tours selon l'objectif de ${personneDe(details)} dépassent ses tours possibles : le calcul est faux.`,
],
[
'TOURS_SERVIS_NON_ASSIS',
(details) => `Les tours selon l'objectif de ${personneDe(details)} dépassent ses tours assis : le calcul est faux.`,
],
[
'ANIMATEURS_RENCONTRES',
(details) => `Les animateurs rencontrés par ${personneDe(details)} dépassent sa borne des animateurs : le calcul est faux.`,
],
['JAMAIS_SERVIES', () => 'Les personnes jamais servies dépassent les personnes concernées : le calcul est faux.'],
[
'HORS_OBJECTIF_MAX',
() => "Le plus de tours hors objectif d'une personne dépasse le plus de tours possibles : le calcul est faux.",
],
// --- Les critères du classement (§ 5.7) -----------------------------------------
['moteur.critere.ecartAuPlafondAPrioriMax', 'écart au plafond a priori'],

View file

@ -1,11 +1,14 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les textes des commandes de la liste des participants et de l'import
// (§ 4.4, § 10.2, § 14.6) : leurs refus, leurs avertissements et leurs
// libellés figés. Cette table ne porte que des codes. Un champ inconnu lève
// RangeError, au lieu de se taire.
import { compte, nomDans } from './aides.js';
// Les textes des commandes de la liste des participants, de leurs
// étiquettes et de l'import des participants (§ 4.4, § 4.5, § 10.2,
// § 14.6) : leurs refus, leurs avertissements et leurs libellés figés. Cette
// table ne porte que des codes. Un champ ou une raison inconnus lèvent
// RangeError, au lieu de se taire. Les bornes d'une étiquette viennent de la
// règle qui les pose.
import { ETIQUETTES_MAX, ETIQUETTE_LONGUEUR_MAX } from '../../stockage/etiquettes.js';
import { cite, compte, enumeration, nomDans } from './aides.js';
// Les champs d'un participant qu'une saisie modifie, dans l'ordre du fichier.
const NOMS_DES_CHAMPS = new Map([
@ -18,6 +21,17 @@ const NOMS_DES_CHAMPS = new Map([
]);
const nomChamp = (champ) => nomDans(NOMS_DES_CHAMPS, champ, 'champ');
// Ce qui rend fautive une étiquette saisie (§ 4.5), raison de
// lireEtiquettes ou saisie vide.
const RAISONS_D_ETIQUETTE = new Map([
['vide', () => 'La saisie ne donne aucune étiquette'],
['controle', (valeur) => `${cite(valeur)} porte un caractère invisible ou de contrôle, tabulation comprise`],
['longueur', (valeur) => `${cite(valeur)} dépasse ${ETIQUETTE_LONGUEUR_MAX} caractères`],
]);
// Des étiquettes citées et énumérées.
const etiquettesCitees = (etiquettes) => enumeration(etiquettes.map(cite));
// Les titres de place qu'un geste sur une personne touche, quand il y en a.
const titresDe = (n, singulier, pluriel) =>
n > 0 ? `, et ${compte(n, 'titre de place', 'titres de place')} ${n <= 1 ? singulier : pluriel}` : '';
@ -44,6 +58,24 @@ export const TABLE = new Map([
`Suppression de ${personne} : ${compte(reservations, 'réservation retirée', 'réservations retirées')}` +
`${titresDe(titres, "n'est plus pourvu", 'ne sont plus pourvus')}.`,
],
[
'ETIQUETTE_INVALIDE',
({ valeur, raison }) =>
`${nomDans(RAISONS_D_ETIQUETTE, raison, "raison d'étiquette")(valeur)} : une étiquette compte de 1 à ` +
`${ETIQUETTE_LONGUEUR_MAX} caractères visibles, et la virgule, le point-virgule et la barre verticale ` +
'séparent deux étiquettes.',
],
[
'ETIQUETTES_EN_TROP',
({ nombre }) =>
`Une personne ou un objectif de table porte au plus ${ETIQUETTES_MAX} étiquettes ; ` +
`celles-ci en feraient ${nombre}.`,
],
[
'ORTHOGRAPHE_RETENUE',
({ saisie, retenue }) =>
`${cite(saisie)} est enregistrée sous l'orthographe que l'événement porte déjà : ${cite(retenue)}.`,
],
[
'IMPORT_EN_RETENU',
() =>
@ -63,6 +95,15 @@ export const TABLE = new Map([
['ENTREE_EXCLURE_PARTICIPANT', ({ personne }) => `Exclu ${personne}`],
['ENTREE_REINTEGRER_PARTICIPANT', ({ personne }) => `Réintégré ${personne}`],
['ENTREE_SUPPRIMER_PARTICIPANT', ({ personne }) => `Supprimé ${personne}`],
[
'ENTREE_CHANGER_ETIQUETTES',
({ personne, ajoutees, retirees }) => {
const parties = [];
if (ajoutees.length > 0) parties.push(`ajouté ${etiquettesCitees(ajoutees)}`);
if (retirees.length > 0) parties.push(`retiré ${etiquettesCitees(retirees)}`);
return `Étiquettes de ${personne} : ${parties.join(' ; ')}`;
},
],
[
'ENTREE_IMPORTER_PARTICIPANTS',
({ mode, ajoutes, misAJour, retires }) => {

View file

@ -0,0 +1,171 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les étiquettes et les objectifs des tables, côté application (§ 4.5,
// § 5.9) : les avis d'un objectif qui ne servira personne ou qui vise une
// étiquette que personne ne porte, et la réception d'une saisie
// d'étiquettes, que les commandes des personnes et des objectifs partagent.
// La forme et la découpe d'une saisie viennent de src/stockage/etiquettes.js,
// l'orthographe reçue de src/csv/etiquettes.js, la correspondance d'une
// personne à un objectif de correspondA (src/moteur/correspondance.js) : ce
// module n'en porte aucune autre arithmétique. Aucune fonction ne modifie ce
// qu'elle reçoit.
import { orthographeRecue } from '../csv/etiquettes.js';
import { correspondA } from '../moteur/correspondance.js';
import { regleObjectifs } from '../stockage/document.js';
import { lireEtiquettes } from '../stockage/etiquettes.js';
import { refus } from './erreurs.js';
// 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);
/**
* L'ordre des objectifs d'une table (§ 8.9), comparateur de sort : la
* soirée, de tour null, avant les tours, puis les tours croissants. Le
* fichier d'état, les avis et les bilans le lisent tous ici.
*
* @param {{tour: number|null}} a
* @param {{tour: number|null}} b
* @returns {number}
*/
export const ordreDesObjectifs = (a, b) => (a.tour ?? 0) - (b.tour ?? 0);
/**
* Une liste d'étiquettes sous la forme que le fichier d'état écrit : une
* copie triée par unités de code (§ 8.9).
*
* @param {Iterable<string>} etiquettes
* @returns {string[]}
*/
export const etiquettesRangees = (etiquettes) => [...etiquettes].sort(comparerTextes);
/**
* Les étiquettes de l'événement entier (§ 4.5) : celles de chaque personne,
* exclue comprise, dans l'ordre de la charge, puis celles de chaque objectif
* de table, dans l'ordre des tables de la charge. Une étiquette portée deux
* fois y est deux fois : la réconciliation ne lit que leur ensemble.
*
* @param {import('../stockage/types.js').Charge} charge
* @returns {string[]}
*/
export function etiquettesDeLEvenement(charge) {
return [
...charge.participants.flatMap(({ etiquettes = [] }) => etiquettes),
...charge.tables.flatMap(({ objectifs = [] }) => objectifs.flatMap(({ etiquettes }) => etiquettes)),
];
}
/**
* Reçoit une saisie d'étiquettes (§ 4.5, D1) : découpée et mise en forme par
* lireEtiquettes, chaque étiquette prend l'orthographe que l'événement porte
* déjà pour sa clé — la plus petite par points de code quand il en porte
* plusieurs —, et une clé que seule la saisie porte, la plus petite des
* orthographes saisies : « Montréal, MONTRÉAL » n'en fait qu'une. Rend
* etiquettes, l'ensemble reçu trié par unités de code, vide pour une saisie
* de blancs et de séparateurs seuls ; et avertissements, un
* ORTHOGRAPHE_RETENUE {saisie, retenue} par orthographe saisie que remplace
* une orthographe de l'événement, dans l'ordre de la saisie. Un doublon
* interne à la saisie, sur une clé que l'événement ne porte pas, se fond
* sans avertissement (§ 4.5). Le nombre d'étiquettes ne se borne pas ici :
* l'appelant le compare à ETIQUETTES_MAX sur la liste qu'il pose.
*
* @param {Iterable<string>} existantes les étiquettes de l'événement,
* personnes et objectifs confondus (etiquettesDeLEvenement)
* @param {string} texte la saisie
* @returns {{etiquettes: string[], avertissements: Array<{code: string, details: {saisie: string, retenue: string}}>}}
* @throws {ErreurCommande} ETIQUETTE_INVALIDE {valeur, raison}, remède null,
* pour le premier morceau fautif : raison 'controle' ou 'longueur'
* @throws {TypeError} une saisie qui n'est pas un texte
*/
export function recevoirEtiquettes(existantes, texte) {
if (typeof texte !== 'string') throw new TypeError(`etiquettes : texte attendu, reçu ${JSON.stringify(texte) ?? String(texte)}`);
const { etiquettes: lues, fautes } = lireEtiquettes(texte);
if (fautes.length > 0) throw refus('ETIQUETTE_INVALIDE', fautes[0]);
const connues = [...existantes];
const etiquettes = new Set();
const avertissements = [];
for (const saisie of lues) {
// existante vaut la saisie quand l'événement ne porte pas sa clé, ou
// quand il la porte sous cette orthographe-là, la plus petite : rien
// n'est alors à dire. La saisie se fond d'abord en la plus petite des
// siennes, puis l'existante l'emporte.
const existante = orthographeRecue(connues, saisie);
etiquettes.add(orthographeRecue(connues, orthographeRecue(lues, saisie)));
if (existante !== saisie) avertissements.push({ code: 'ORTHOGRAPHE_RETENUE', details: { saisie, retenue: existante } });
}
return { etiquettes: etiquettesRangees(etiquettes), avertissements };
}
// Ce que les avis lisent de la charge : les listes d'étiquettes des
// personnes présentes, que lit la correspondance ; les étiquettes de toutes
// les personnes, exclues comprises, à la suite, que lisent les orphelines
// (A12 : « qu'aucune personne ne porte ») ; et la règle par défaut.
function lectureDesAvis(charge) {
const presentes = charge.participants.filter(({ exclu }) => !exclu).map(({ etiquettes = [] }) => etiquettes);
const portees = charge.participants.flatMap(({ etiquettes = [] }) => etiquettes);
return { presentes, portees, portee: new Set(portees), parDefaut: regleObjectifs(charge) };
}
// Les avis d'un objectif de la table id, sur la lecture donnée : celui de
// la correspondance, puis une étiquette orpheline par étiquette que personne
// ne porte et que taire n'écarte pas, dans l'ordre de sa liste.
function avisDe(lecture, id, { tour, regle, etiquettes }, taire = () => false) {
const avis = [];
const visee = { regle: regle ?? lecture.parDefaut, etiquettes };
if (!lecture.presentes.some((siennes) => correspondA(siennes, visee))) {
avis.push({ code: 'OBJECTIF_SANS_CORRESPONDANT', details: { table: id, tour } });
}
for (const motCle of etiquettes) {
if (lecture.portee.has(motCle) || taire(motCle)) continue;
const voisine = orthographeRecue(lecture.portees, motCle);
avis.push({ code: 'ETIQUETTE_ORPHELINE', details: { etiquette: motCle, voisine: voisine === motCle ? null : voisine } });
}
return avis;
}
/**
* Les avis des objectifs de l'événement (§ 5.9) : acceptés et signalés, ils
* n'empêchent rien. Pour chaque objectif — les tables par identifiant
* croissant, puis la soirée et les tours croissants —, d'abord
* OBJECTIF_SANS_CORRESPONDANT {table, tour} quand aucune personne présente ne
* lui correspond sous sa règle, la propre ou celle de l'événement
* (regleObjectifs) ; puis ETIQUETTE_ORPHELINE {etiquette, voisine} pour
* chacune de ses étiquettes qu'aucune personne ne porte, exclue comprise
* (A12), dans l'ordre de sa liste, une étiquette se disant à son premier
* objectif seulement. voisine est l'orthographe de même clé qu'une personne
* porte, la plus petite par points de code, null quand il n'y en a pas.
* table est l'identifiant de la table ; tour est null pour l'objectif de la
* soirée.
*
* @param {import('../stockage/types.js').Charge} charge
* @returns {Array<{code: 'OBJECTIF_SANS_CORRESPONDANT'|'ETIQUETTE_ORPHELINE', details: Object}>}
*/
export function avisObjectifs(charge) {
const lecture = lectureDesAvis(charge);
const dites = new Set();
const taire = (motCle) => {
const deja = dites.has(motCle);
dites.add(motCle);
return deja;
};
return [...charge.tables]
.sort((a, b) => a.id - b.id)
.flatMap(({ id, objectifs = [] }) => [...objectifs].sort(ordreDesObjectifs).flatMap((objectif) => avisDe(lecture, id, objectif, taire)));
}
/**
* Les avis du seul objectif de la table id au tour donné, null pour la
* soirée, sous la forme d'avisObjectifs : chacune de ses étiquettes
* orphelines se dit, même quand un autre objectif la vise aussi. [] quand
* la table ne porte pas cet objectif.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {number} id
* @param {number|null} tour
* @returns {Array<{code: string, details: Object}>}
*/
export function avisDeLObjectif(charge, id, tour) {
const objectif = charge.tables.find((table) => table.id === id)?.objectifs?.find((candidat) => candidat.tour === tour);
return objectif === undefined ? [] : avisDe(lectureDesAvis(charge), id, objectif);
}

View file

@ -0,0 +1,198 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des étiquettes et des objectifs côté application (§ 4.5, § 5.9) :
// les avis d'objectifs — un objectif auquel aucune personne présente ne
// correspond, une étiquette d'objectif que personne ne porte —, et la
// réception d'une saisie d'étiquettes, réconciliée sur l'événement entier.
// Les noms et les étiquettes d'épreuve sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { creerCharge } from '../stockage/document.js';
import { avisDeLObjectif, avisObjectifs, etiquettesDeLEvenement, recevoirEtiquettes } from './objectifs.js';
const personne = (id, nom, etiquettes, autres = {}) => ({
id,
nom,
prenom: null,
appartenance: null,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
...(etiquettes === undefined ? {} : { etiquettes }),
...autres,
});
const objectif = (tour, etiquettes, regle = null) => ({ portee: tour === null ? 'tous' : 'tour', tour, regle, etiquettes });
const table = (id, objectifs) => ({
id,
numero: id,
sieges: null,
forme: 'ronde',
position: { x: 300 * id, y: 0 },
...(objectifs === undefined ? {} : { objectifs }),
});
// Trois personnes : Iris Ombrelle « finance » et « Montréal », Théo
// Pervenche « finance », Ondine Lacasse sans étiquette ; trois tours.
function chargeDe(tables, { participants } = {}) {
const charge = creerCharge({ id: 'evt-objectifs', nom: 'Soirée des Hérons', siegesParDefaut: 4, tours: 3 });
charge.participants = participants ?? [
personne(1, 'Ombrelle', ['Montréal', 'finance']),
personne(2, 'Pervenche', ['finance']),
personne(3, 'Lacasse'),
];
charge.tables = tables;
charge.prochainsIds = { participant: charge.participants.length + 1, table: 9, proposition: 1 };
return charge;
}
// Gèle une valeur en profondeur : une fonction qui la modifierait lèverait.
function figer(valeur) {
if (valeur !== null && typeof valeur === 'object' && !Object.isFrozen(valeur)) {
Object.freeze(valeur);
for (const element of Object.values(valeur)) figer(element);
}
return valeur;
}
describe('objectifs : les avis (§ 4.5, § 5.9)', () => {
test('sans objectif, aucun avis ; un objectif servi, aucun avis', () => {
assert.deepEqual(avisObjectifs(chargeDe([table(1), table(2)])), []);
assert.deepEqual(avisObjectifs(chargeDe([table(1, [objectif(null, ['finance'])])])), []);
});
test('« toutes » sans personne qui porte chaque étiquette : OBJECTIF_SANS_CORRESPONDANT {table, tour} ; « au moins une », servi', () => {
const charge = chargeDe([table(1, [objectif(2, ['finance', 'ressources humaines'], 'et')])]);
charge.participants.push(personne(4, 'Grisaille', ['ressources humaines']));
assert.deepEqual(avisObjectifs(charge), [{ code: 'OBJECTIF_SANS_CORRESPONDANT', details: { table: 1, tour: 2 } }]);
charge.tables[0].objectifs[0].regle = 'ou';
assert.deepEqual(avisObjectifs(charge), []);
});
test('un objectif qui suit le défaut lit la règle de l’événement ; une règle propre ne la suit pas', () => {
const charge = chargeDe([
table(1, [objectif(null, ['finance', 'bénévole'])]),
table(2, [objectif(null, ['finance', 'bénévole'], 'ou')]),
]);
charge.participants.push(personne(4, 'Grisaille', ['bénévole']));
assert.deepEqual(avisObjectifs(charge), []);
charge.reglages.regleObjectifs = 'et';
assert.deepEqual(avisObjectifs(charge), [{ code: 'OBJECTIF_SANS_CORRESPONDANT', details: { table: 1, tour: null } }]);
});
test('une personne exclue ne sert aucun objectif, mais son étiquette n’est pas orpheline', () => {
// A12 : « une étiquette d'objectif qu'aucune personne ne porte », exclue
// comprise ; seule la correspondance lit les personnes présentes.
const charge = chargeDe([table(1, [objectif(null, ['bénévole', 'Voile'])])]);
charge.participants.push(personne(4, 'Grisaille', ['bénévole', 'voile'], { exclu: true }));
assert.deepEqual(avisObjectifs(charge), [
{ code: 'OBJECTIF_SANS_CORRESPONDANT', details: { table: 1, tour: null } },
{ code: 'ETIQUETTE_ORPHELINE', details: { etiquette: 'Voile', voisine: 'voile' } },
]);
});
test('une étiquette d’objectif que personne ne porte : ETIQUETTE_ORPHELINE, avec l’orthographe voisine que porte une personne', () => {
// « Finance » et « finance » ne se réunissent qu'à l'entrée : un fichier
// retouché à la main peut les porter toutes deux.
const charge = chargeDe([table(1, [objectif(null, ['Finance', 'Montréal'])])]);
assert.deepEqual(avisObjectifs(charge), [{ code: 'ETIQUETTE_ORPHELINE', details: { etiquette: 'Finance', voisine: 'finance' } }]);
});
test('une étiquette orpheline visée par deux objectifs se dit une fois, à son premier objectif', () => {
const charge = chargeDe([
table(2, [objectif(null, ['finance', 'voile']), objectif(1, ['voile'])]),
table(1, [objectif(3, ['voile', 'Montréal'])]),
]);
assert.deepEqual(avisObjectifs(charge), [
{ code: 'ETIQUETTE_ORPHELINE', details: { etiquette: 'voile', voisine: null } },
{ code: 'OBJECTIF_SANS_CORRESPONDANT', details: { table: 2, tour: 1 } },
]);
});
test('l’ordre : les tables par identifiant, la soirée puis les tours croissants, quel que soit l’ordre de la charge', () => {
const charge = chargeDe([
table(3, [objectif(2, ['voile']), objectif(null, ['escalade'])]),
table(1, [objectif(1, ['plongée'])]),
]);
assert.deepEqual(
avisObjectifs(figer(charge)).map(({ code, details }) => [code, details.table ?? details.etiquette, details.tour ?? null]),
[
['OBJECTIF_SANS_CORRESPONDANT', 1, 1],
['ETIQUETTE_ORPHELINE', 'plongée', null],
['OBJECTIF_SANS_CORRESPONDANT', 3, null],
['ETIQUETTE_ORPHELINE', 'escalade', null],
['OBJECTIF_SANS_CORRESPONDANT', 3, 2],
['ETIQUETTE_ORPHELINE', 'voile', null],
],
);
});
});
describe('objectifs : les avis d’un seul objectif', () => {
test('avisDeLObjectif dit chaque étiquette orpheline de cet objectif, même déjà dite à un autre ; [] sans objectif au couple', () => {
const charge = chargeDe([table(1, [objectif(null, ['voile'])]), table(2, [objectif(null, ['finance']), objectif(2, ['voile', 'finance'])])]);
assert.deepEqual(avisDeLObjectif(charge, 2, 2), [{ code: 'ETIQUETTE_ORPHELINE', details: { etiquette: 'voile', voisine: null } }]);
assert.deepEqual(avisDeLObjectif(charge, 2, null), []);
assert.deepEqual(avisDeLObjectif(charge, 2, 1), []);
assert.deepEqual(avisDeLObjectif(charge, 9, null), []);
});
});
describe('objectifs : la saisie d’étiquettes (§ 4.5, D1)', () => {
test('etiquettesDeLEvenement : les étiquettes des personnes, puis celles des objectifs, dans l’ordre de la charge, doublons admis', () => {
const charge = chargeDe([table(2, [objectif(null, ['voile', 'finance'])]), table(1, [objectif(1, ['Montréal'])])]);
assert.deepEqual(etiquettesDeLEvenement(charge), ['Montréal', 'finance', 'finance', 'voile', 'finance', 'Montréal']);
});
test('une saisie reçoit l’orthographe existante de sa clé, et le dit ; triée par unités de code', () => {
const recue = recevoirEtiquettes(['finance', 'Montréal'], ' FINANCE ; voile | montreal ');
assert.deepEqual(recue.etiquettes, ['Montréal', 'finance', 'voile']);
assert.deepEqual(recue.avertissements, [
{ code: 'ORTHOGRAPHE_RETENUE', details: { saisie: 'FINANCE', retenue: 'finance' } },
{ code: 'ORTHOGRAPHE_RETENUE', details: { saisie: 'montreal', retenue: 'Montréal' } },
]);
});
test('deux orthographes déjà présentes : la plus petite par points de code est reçue', () => {
assert.deepEqual(recevoirEtiquettes(['finance', 'Finance'], 'FINANCE').etiquettes, ['Finance']);
});
test('deux orthographes d’une clé neuve dans la même saisie se fondent en la plus petite, sans avertissement', () => {
// L'événement ne porte pas la clé : aucune orthographe n'est « déjà
// portée », et le doublon se fond en silence (§ 4.5).
const recue = recevoirEtiquettes([], 'Montréal, MONTRÉAL, Montréal');
assert.deepEqual(recue.etiquettes, ['MONTRÉAL']);
assert.deepEqual(recue.avertissements, []);
});
test('deux orthographes d’une clé que l’événement porte : chacune reçoit l’existante, et chacune le dit', () => {
const recue = recevoirEtiquettes(['Montréal'], 'MONTRÉAL, montreal, Montréal');
assert.deepEqual(recue.etiquettes, ['Montréal']);
assert.deepEqual(recue.avertissements, [
{ code: 'ORTHOGRAPHE_RETENUE', details: { saisie: 'MONTRÉAL', retenue: 'Montréal' } },
{ code: 'ORTHOGRAPHE_RETENUE', details: { saisie: 'montreal', retenue: 'Montréal' } },
]);
});
test('une saisie décomposée entre composée ; les séparateurs seuls ne donnent aucune étiquette', () => {
assert.deepEqual(recevoirEtiquettes([], 'Montre\u{0301}al').etiquettes, ['Montréal']);
assert.deepEqual(recevoirEtiquettes([], ' ,; | ').etiquettes, []);
});
test('un morceau fautif refuse la saisie entière : ETIQUETTE_INVALIDE {valeur, raison}, remède null', () => {
assert.throws(
() => recevoirEtiquettes([], 'finance, a\u{0009}b'),
(erreur) => {
assert.equal(erreur.code, 'ETIQUETTE_INVALIDE');
assert.deepEqual(erreur.details, { valeur: 'a\u{0009}b', raison: 'controle', remede: null });
return true;
},
);
assert.throws(() => recevoirEtiquettes([], 'x'.repeat(41)), (erreur) => erreur.details.raison === 'longueur');
assert.deepEqual(recevoirEtiquettes([], 'x'.repeat(40)).etiquettes, ['x'.repeat(40)]);
assert.throws(() => recevoirEtiquettes([], null), TypeError);
});
});

View file

@ -15,8 +15,9 @@ import { ordreDesReservations, pourvoyeursDesTitres } from '../csv/import.js';
import { STATUT, indexerPlan, normaliser } from '../moteur/configuration.js';
import { conflitsParTable } from '../moteur/conflits.js';
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { capacite, configurationDepuisCharge } from '../stockage/document.js';
import { capacite } from '../stockage/document.js';
import { derive, fautes, planDepuisFichier } from '../stockage/placements.js';
import { configurationDe } from './configuration.js';
import { libelle } from './libelles.js';
const croissant = (a, b) => a - b;
@ -41,7 +42,7 @@ export function reservationsActives(charge) {
*/
export function instanceDe(charge) {
try {
return normaliser(configurationDepuisCharge(charge));
return normaliser(configurationDe(charge));
} catch (erreur) {
if (erreur instanceof ErreurConfiguration) return null;
throw erreur;
@ -364,7 +365,7 @@ const NOMS_DES_STATUTS = new Map([
export function statuts(charge) {
let instance;
try {
instance = normaliser(configurationDepuisCharge(charge));
instance = normaliser(configurationDe(charge));
} catch (erreur) {
if (erreur instanceof ErreurConfiguration) return { parId: new Map(), erreur: erreur.code };
throw erreur;

View file

@ -11,10 +11,11 @@ import { readFileSync } from 'node:fs';
import { describe, test } from '../../test/lanceur.js';
import { STATUT, indexerPlan, normaliser } from '../moteur/configuration.js';
import { conflitsParTable } from '../moteur/conflits.js';
import { analyser, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
import { analyser, creerCharge } from '../stockage/document.js';
import { planDepuisFichier } from '../stockage/placements.js';
import { VERSION } from '../version.genere.js';
import { COMMANDES } from './commandes.js';
import { configurationDe } from './configuration.js';
import { titresNonPourvus } from './listes_travail.js';
import { etatPlaces, libellePropose, repartirSieges, statuts } from './places.js';
@ -105,7 +106,7 @@ const occupants = (etat, rang) => etat.tables[rang].chaises.map(({ occupant }) =
// Les conflits que le moteur lit sur ce placement, tour par tour.
function conflitsDuMoteur(charge, placement) {
const instance = normaliser(configurationDepuisCharge(charge));
const instance = normaliser(configurationDe(charge));
return conflitsParTable(instance, indexerPlan(instance, planDepuisFichier(placement)));
}
@ -211,7 +212,7 @@ describe('etatPlaces : la source et la cible (§ 5.8, § 8.9)', () => {
charge.reservations = [resa(1, 1, null), resa(5, 1, null, 2)];
const etat = etatPlaces(charge, { tour: 2 });
assert.equal(etat.evalue, true);
const instance = normaliser(configurationDepuisCharge(charge));
const instance = normaliser(configurationDe(charge));
assert.deepEqual(etat.tables[0].conflits, (({ nombre, regles }) => ({ nombre, regles }))(conflitsParTable(instance, instance.fixe)[1][0]));
assert.ok(etat.tables[0].conflits.nombre > 0);
});
@ -558,7 +559,7 @@ describe('libellePropose et statuts (§ 4, § 4.2)', () => {
const { parId, erreur } = statuts(charge);
assert.equal(erreur, null);
assert.deepEqual([...parId.entries()], [[1, 'ancre'], [2, 'ancre'], [3, 'partiel'], [4, 'mobile']]);
const instance = normaliser(configurationDepuisCharge(charge));
const instance = normaliser(configurationDe(charge));
const NOMS = new Map([[STATUT.ANCRE, 'ancre'], [STATUT.PARTIELLEMENT_FIXE, 'partiel'], [STATUT.MOBILE, 'mobile']]);
assert.ok(parId.size > 0);
for (const [id, statut] of parId) assert.equal(statut, NOMS.get(instance.statut[instance.indexDe.get(id)]));

View file

@ -43,7 +43,7 @@ import { creer as creerDansLeDepot } from '../stockage/depot.js';
import { analyser, configurationDepuisCharge } from '../stockage/document.js';
import { lireJournal, reconstruire } from '../stockage/journal.js';
import { SUFFIXES } from '../stockage/noms.js';
import { planDepuisFichier } from '../stockage/placements.js';
import { derive, planDepuisFichier } from '../stockage/placements.js';
import { VERSION } from '../version.genere.js';
import { COMMANDES } from './commandes.js';
import { creerFacade } from './evenements.js';
@ -117,6 +117,11 @@ const REMEDE_NUL_ADMIS = new Map([
['RESERVATION_ABSENTE', "la réservation visée est absente, aucune commande ne la produit, et l'interface n'offre le retrait que sur une réservation présente"],
['TITRE_ABSENT', "le titre visé est absent, aucune commande ne le produit, et l'interface n'offre le retrait que sur un titre présent"],
['CONVERSION_IMPOSSIBLE', "les réservations « tour » visées sont absentes, aucune commande ne les produit, et l'interface n'offre la conversion que sur des réservations présentes"],
['ETIQUETTE_INVALIDE', 'la saisie se corrige dans le champ même, dont le texte dit la forme attendue'],
['ETIQUETTES_EN_TROP', 'la saisie se corrige dans le champ même, dont le texte dit le nombre admis'],
['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'],
]);
// Le geste qui pose deux places de plus à la table 2, par son champ.
@ -344,6 +349,52 @@ const EXEMPLES = new Map([
sansEffet: { args: { cible: '1000' } },
},
],
[
'changerEtiquettes',
{
accepte: { args: { id: 2, ajouter: 'finance, Montréal' } },
personne: () => 2,
refuse: { args: { id: 2, ajouter: 'ressources\u{0009}humaines' }, code: 'ETIQUETTE_INVALIDE', remede: null },
sansEffet: { avant: [['changerEtiquettes', { id: 2, ajouter: 'finance' }]], args: { id: 2, ajouter: 'Finance' } },
},
],
[
'poserObjectif',
{
accepte: { args: { table: 2, tour: null, etiquettes: 'finance', regle: null } },
refuse: {
avant: [['poserObjectif', { table: 2, tour: 3, etiquettes: 'finance', regle: 'et' }]],
args: { table: 2, tour: 3, etiquettes: ' ; ', regle: null },
code: 'OBJECTIF_SANS_ETIQUETTE',
remede: { commande: 'retirerObjectif', arguments: { table: 2, tour: 3 } },
},
sansEffet: {
avant: [['poserObjectif', { table: 2, tour: 1, etiquettes: 'finance, Montréal', regle: 'et' }]],
args: { table: 2, tour: 1, etiquettes: 'Montréal | finance', regle: 'et' },
},
},
],
[
'retirerObjectif',
{
accepte: { avant: [['poserObjectif', { table: 2, tour: 2, etiquettes: 'finance', regle: null }]], args: { table: 2, tour: 2 } },
refuse: {
avant: [['poserObjectif', { table: 4, tour: null, etiquettes: 'finance', regle: null }], ['supprimerTable', { id: 4 }]],
args: { table: 4, tour: null },
code: 'TABLE_DISPARUE',
remede: null,
},
sansEffet: { args: { table: 2, tour: null } },
},
],
[
'changerRegleObjectifs',
{
accepte: { args: { regle: 'et' } },
refuse: "la règle se choisit entre deux valeurs, que l'interface offre seules : changer la règle ne refuse rien du domaine",
sansEffet: { args: { regle: 'ou' } },
},
],
[
'reserver',
{
@ -1081,3 +1132,38 @@ describe('registre : créer un événement et charger une démonstration écrive
assert.equal(fs.contenu(racine.id, base + SUFFIXES.verrou), null);
});
});
// --- Les étiquettes, les objectifs, leur règle et les cases ne font aucune dérive ---
describe('registre : étiquettes, objectifs, règle et cases ne mettent aucun placement en dérive (§ 8.9, D13)', () => {
// Les raisons de dérive de chaque proposition, puis du retenu.
const raisons = (charge) => [...charge.propositions, charge.retenu].map((placement) => derive(placement, charge));
test('sur la charge témoin, chaque geste écrit une entrée et laisse les raisons de dérive telles quelles', async () => {
const ouvert = await ouvrirCopie((await departsPrets()).temoin, { ecriture: true });
const { seance } = ouvert;
const gestes = [
['changerEtiquettes', { id: 2, ajouter: 'finance' }],
['changerEtiquettes', { id: 3, ajouter: 'Montréal' }],
['poserObjectif', { table: 2, tour: null, etiquettes: 'finance', regle: null }],
['poserObjectif', { table: 3, tour: 2, etiquettes: 'finance, montreal', regle: 'et' }],
['changerRegleObjectifs', { regle: 'et' }],
['modifierReglage', { cle: 'respecterObjectifs', valeur: false }],
['modifierReglage', { cle: 'rencontrerAnimateurs', valeur: true }],
['retirerObjectif', { table: 2, tour: null }],
['changerEtiquettes', { id: 2, retirer: 'finance' }],
];
const depart = raisons(seance.charge);
for (const [nom, args] of gestes) {
const entrees = journalDe(ouvert).entrees.length;
await seance.executer(nom, args);
assert.equal(journalDe(ouvert).entrees.length, entrees + 1, nom);
assert.deepEqual(raisons(seance.charge), depart, nom);
}
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'] },
]);
});
});

View file

@ -23,16 +23,21 @@
// valide ou refusée pour un seul motif ; zéro ligne valide refuse ;
// 7. les appartenances des lignes valides se réconcilient avec celles des
// existants, et les doublons se relèvent ; en mode remplacer, qui
// retire chaque existant, avec ceux du fichier seuls ;
// retire chaque existant, avec ceux du fichier seuls. Leurs étiquettes
// se réconcilient avec celles des existants, hors du mode remplacer, et
// avec celles des objectifs de table dans chaque mode : les objectifs
// appartiennent aux tables, que remplacer garde ;
// 8. en mode mettreAJour, les lignes ajoutées à côté d'un existant de même
// nom se nomment ; dans chaque mode, les cellules qui s'étendent sur
// plusieurs lignes du texte et portent le séparateur.
// Une ligne refusée ne refuse pas le texte : le reste s'importe, et la ligne
// revient dans le CSV des refus (§ 10.1).
import { ETIQUETTES_MAX, lireEtiquettes } from '../stockage/etiquettes.js';
import { associer, reconnaitre } from './colonnes.js';
import { contientNul } from './encodage.js';
import { ErreurCsv } from './erreurs.js';
import { cleEtiquette, reconcilier as reconcilierEtiquettes } from './etiquettes.js';
import { choisirSeparateur, decouper, enregistrementVide } from './lecture.js';
import { cleNormalisee } from './normalisation.js';
@ -78,6 +83,12 @@ export const VALEURS_EXCLU = new Map([
* @property {boolean|null} exclu null pour une cellule vide ou une
* colonne absente : vide vaut non pour une personne créée, et laisse
* la valeur d'une personne existante qu'un import met à jour
* @property {string[]|null} etiquettes les étiquettes de la cellule,
* 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é, ETIQUETTES_MAX au plus ; null pour une colonne absente
* ou une cellule qui n'en donne aucune — vide, blanche ou faite de
* séparateurs —, qui laisse celles d'une personne mise à jour
*
* @typedef {Object} Apercu
* @property {'ajouter'|'mettreAJour'|'remplacer'} mode le mode pour lequel
@ -93,10 +104,15 @@ export const VALEURS_EXCLU = new Map([
* les lignes valides ; ligne : rang de l'enregistrement à partir de
* 1, l'en-tête compris ; champs : chaînes en NFC, blancs de bord
* retirés, vide → null ; brut : les champs tels que découpés
* @property {Array<{ligne: number, code: string, valeur: string|null, brut: string[]}>} refusees
* code : CHAMPS_EN_TROP, NOM_ABSENT, EXCLU_INCONNU ; valeur : la
* valeur d'« exclu » refusée, en NFC sans ses blancs de bord, null
* pour les deux autres codes
* @property {Array<{ligne: number, code: string, valeur: string|null, raison?: string, nombre?: number, brut: string[]}>} refusees
* code : CHAMPS_EN_TROP, NOM_ABSENT, EXCLU_INCONNU,
* ETIQUETTE_MAL_FORMEE, ETIQUETTES_TROP_NOMBREUSES ; valeur : la
* valeur d'« exclu » refusée, en NFC sans ses blancs de bord, ou la
* première étiquette fautive de la cellule, mise en forme ; null
* pour les autres codes ; raison, sous ETIQUETTE_MAL_FORMEE :
* 'controle' ou 'longueur', celle de lireEtiquettes ; nombre, sous
* ETIQUETTES_TROP_NOMBREUSES : les étiquettes de clés distinctes
* que porte la cellule
* @property {Array<{affichee: string, orthographes: Array<{texte: string, lignes: number[]}>}>} fusions
* chaque groupe d'au moins deux orthographes, existantes comprises
* hors du mode remplacer, qui porte une ligne valide, rangé par sa
@ -104,6 +120,11 @@ export const VALEURS_EXCLU = new Map([
* orthographes dans l'ordre de rencontre, la première affichée ;
* lignes : rangs des lignes valides de cette orthographe, vide pour
* une orthographe que seuls des participants existants portent
* @property {import('./etiquettes.js').FusionEtiquettes[]} fusionsEtiquettes
* les fusions d'orthographes d'étiquettes que rend reconcilier
* (etiquettes.js) sur les lignes valides : chacune nomme
* l'orthographe retenue, les orthographes de sa clé et son nombre
* de lignes réécrites ; distinctes des fusions des appartenances
* @property {Array<{cle: string, lignes: number[], existants: number[]}>} doublons
* chaque triplet (nom, prénom, appartenance) normalisé que portent au
* moins deux personnes, dont une ligne valide, rangé par sa première
@ -130,8 +151,8 @@ export const VALEURS_EXCLU = new Map([
* lignes a la même forme, et l'export l'écrit ainsi (§ 10.2) :
* l'aperçu la nomme sans refuser la ligne
* @property {null|'CARACTERE_NUL'|'AUCUN_ENTETE'|'SEPARATEUR_INTROUVABLE'|'GUILLEMET_OUVERT'|'NOM_NON_ASSOCIE'|'AUCUNE_LIGNE_VALIDE'} refusGlobal
* sous un refus global, lignes, fusions, doublons, homonymes et
* multilignes sont vides, et refusees aussi, sauf sous AUCUNE_LIGNE_VALIDE, dont les lignes
* sous un refus global, lignes, fusions, fusionsEtiquettes,
* doublons, homonymes et multilignes sont vides, et refusees aussi, sauf sous AUCUNE_LIGNE_VALIDE, dont les lignes
* refusées sont la cause
* @property {number|null} refusGlobalLigne sous GUILLEMET_OUVERT, le rang de
* l'enregistrement où le guillemet s'ouvre ; sous
@ -156,6 +177,7 @@ function refuserEn(
lignes: [],
refusees: [],
fusions: [],
fusionsEtiquettes: [],
doublons: [],
homonymes: [],
multilignes: [],
@ -181,12 +203,38 @@ function valeurDe(brut, rang) {
// sur chaque appartenance, lue ou existante.
const appartenanceNommee = (texte) => typeof texte === 'string' && cleNormalisee(texte) !== '';
// Les étiquettes d'une cellule (§ 4.5, § 10.1), par la découpe de la saisie
// (lireEtiquettes) : { etiquettes }, null quand la cellule n'en donne aucune,
// ou le motif du refus de la ligne. Une étiquette mal formée refuse,
// ETIQUETTE_MAL_FORMEE, la première nommée avec sa raison ; puis plus
// d'ETIQUETTES_MAX clés distinctes, ETIQUETTES_TROP_NOMBREUSES : deux
// morceaux de même clé ne font qu'une étiquette, que la réconciliation
// fond. Tronquer ou écarter en silence ferait manquer un objectif sans que
// rien ne le dise. La cellule arrive telle que découpée, sans le trim de
// valeurDe : trim retire au bord une tabulation ou U+FEFF, que
// lireEtiquettes refuse, et la ligne ne dépendrait plus que de leur place.
// cellule : undefined quand le champ n'a pas de colonne ou que
// l'enregistrement s'arrête avant elle.
function etiquettesDe(cellule) {
if (cellule === undefined) return { etiquettes: null };
const { etiquettes, fautes } = lireEtiquettes(cellule);
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: etiquettes.length === 0 ? null : etiquettes };
}
// Lit un enregistrement de données selon les colonnes associées, largeur
// étant le nombre d'en-têtes. Rend { champs }, l'appartenance telle qu'écrite
// ou null quand elle n'en nomme aucune, ou le motif du refus { code, valeur },
// un seul, dans cet ordre : un champ non vide au-delà de l'en-tête, qui dit
// une ligne décalée dont aucune valeur n'est sûre ; un nom vide ; une valeur
// d'« exclu » hors de la liste fermée.
// ou null quand elle n'en nomme aucune, les étiquettes telles que découpées,
// ou le motif du refus { code, valeur }, avec raison ou nombre pour les
// étiquettes, un seul, dans cet ordre : un champ non vide au-delà de
// l'en-tête, qui dit une ligne décalée dont aucune valeur n'est sûre ; un nom
// vide ; une valeur d'« exclu » hors de la liste fermée ; une étiquette mal
// formée ; trop d'étiquettes.
function lireLigne(brut, largeur, colonnes) {
if (!enregistrementVide(brut.slice(largeur))) return { code: 'CHAMPS_EN_TROP', valeur: null };
const lire = (champ) => valeurDe(brut, colonnes[champ]);
@ -195,6 +243,8 @@ function lireLigne(brut, largeur, colonnes) {
const valeurExclu = lire('exclu');
const exclu = valeurExclu === null ? null : VALEURS_EXCLU.get(cleNormalisee(valeurExclu));
if (exclu === undefined) return { code: 'EXCLU_INCONNU', valeur: valeurExclu };
const { etiquettes, refus } = etiquettesDe(colonnes.etiquettes === undefined ? undefined : brut[colonnes.etiquettes]);
if (refus !== undefined) return refus;
const appartenance = lire('appartenance');
return {
champs: {
@ -205,10 +255,22 @@ function lireLigne(brut, largeur, colonnes) {
titrePressenti: lire('titre_pressenti'),
notes: lire('notes'),
exclu,
etiquettes,
},
};
}
// Les étiquettes de l'événement qui donnent leur orthographe à celles d'un
// fichier importé (§ 10.1) : celles des participants existants — aucun
// sous remplacer, qui les retire —, puis celles des objectifs de table,
// dans chaque mode. reconcilier ne dépend pas de leur ordre.
function etiquettesExistantes(existants, tables) {
return [
...existants.flatMap(({ etiquettes = [] }) => etiquettes),
...tables.flatMap(({ objectifs = [] }) => objectifs.flatMap(({ etiquettes }) => etiquettes)),
];
}
// Réconcilie les appartenances (§ 10.1). Les orthographes de même clé
// normalisée forment un groupe, que représente la première rencontrée :
// celles des participants existants d'abord, dans l'ordre de existants,
@ -367,13 +429,17 @@ function multilignesDe(lignes, separateur) {
* remplacer, chaque existant est retiré avant que les lignes s'ajoutent :
* aucune ligne n'en est le doublon, et aucune orthographe existante ne
* s'affiche ni ne fond avec celles du fichier. Ajouter et mettre à jour
* lisent le fichier contre les existants.
* lisent le fichier contre les existants. Les étiquettes des objectifs de
* table donnent leur orthographe dans chaque mode.
*
* @param {string} texte le texte que rend decoder, ou celui d'un collage
* @param {Object} [options]
* @param {Array<{id: number, nom: string, prenom: string|null, appartenance: string|null}>} [options.participants]
* les participants existants, dans n'importe quel ordre : l'identifiant
* ordonne leurs orthographes
* @param {Array<{objectifs?: Array<{etiquettes: string[]}>}>} [options.tables]
* les tables de l'événement, dont seules les étiquettes des objectifs se
* lisent
* @param {Object<string, number>} [options.choix] { champ: rang } qui tranche
* une ambiguïté de l'en-tête (voir associer)
* @param {'ajouter'|'mettreAJour'|'remplacer'} [options.mode] ajouter par
@ -382,7 +448,7 @@ function multilignesDe(lignes, separateur) {
* @throws {TypeError} quand texte n'est pas une chaîne : des octets passent
* d'abord par decoder ; pour un mode inconnu
*/
export function apercevoir(texte, { participants = [], choix = {}, mode = 'ajouter' } = {}) {
export function apercevoir(texte, { participants = [], tables = [], choix = {}, mode = 'ajouter' } = {}) {
if (typeof texte !== 'string') throw new TypeError('texte : chaîne attendue');
if (!MODES.includes(mode)) throw new TypeError(`mode : ${MODES.join(', ')} attendu, reçu ${String(mode)}`);
// Chaque refus global porte le mode de l'aperçu.
@ -422,17 +488,25 @@ export function apercevoir(texte, { participants = [], choix = {}, mode = 'ajout
const brut = enregistrements[indice];
if (enregistrementVide(brut)) continue;
const ligne = indice + 1;
const lecture = lireLigne(brut, entetes.length, association.colonnes);
if (lecture.champs === undefined) refusees.push({ ligne, code: lecture.code, valeur: lecture.valeur, brut });
else valides.push({ ligne, champs: lecture.champs, brut });
const { champs: lus, ...refus } = lireLigne(brut, entetes.length, association.colonnes);
if (lus === undefined) refusees.push({ ligne, ...refus, brut });
else valides.push({ ligne, champs: lus, brut });
}
if (valides.length === 0) return { ...refuser('AUCUNE_LIGNE_VALIDE', lu), refusees };
const existants = mode === 'remplacer' ? [] : [...participants].sort((a, b) => a.id - b.id);
const { afficheeDe, fusions } = reconcilier(existants, valides);
const lignes = valides.map(({ ligne, champs, brut }) => ({
const { listes, fusions: fusionsEtiquettes } = reconcilierEtiquettes(
etiquettesExistantes(existants, tables),
valides.map(({ champs }) => champs.etiquettes),
);
const lignes = valides.map(({ ligne, champs, brut }, rang) => ({
ligne,
champs: { ...champs, appartenance: champs.appartenance === null ? null : afficheeDe(champs.appartenance) },
champs: {
...champs,
appartenance: champs.appartenance === null ? null : afficheeDe(champs.appartenance),
etiquettes: listes[rang],
},
brut,
}));
return {
@ -441,6 +515,7 @@ export function apercevoir(texte, { participants = [], choix = {}, mode = 'ajout
lignes,
refusees,
fusions,
fusionsEtiquettes,
doublons: doublonsDe(existants, lignes),
homonymes: mode === 'mettreAJour' ? homonymesDe(participants, lignes) : [],
multilignes: multilignesDe(lignes, separateur),

View file

@ -4,19 +4,23 @@
// Épreuves de l'aperçu d'un import ou d'un collage (§ 10.1, § 10.2, § 14.10) :
// les lignes valides et leurs champs, les lignes refusées et leur motif, les
// refus globaux, la valeur d'« exclu », le rapport des orthographes fondues,
// les doublons signalés et jamais fusionnés, le même aperçu pour un texte
// collé et pour le même texte décodé d'un fichier, et les données d'épreuve
// de test/fixtures/csv/, chacune un CSV tel qu'un tableur l'écrit. Les lettres
// décomposées et les blancs autres que l'espace s'écrivent par leur point de
// code.
// les doublons signalés et jamais fusionnés, la colonne des étiquettes, lue
// par la découpe de la saisie et réconciliée avec l'événement, le même aperçu
// pour un texte collé et pour le même texte décodé d'un fichier, les données
// d'épreuve de test/fixtures/csv/, chacune un CSV tel qu'un tableur l'écrit,
// et la colonne des étiquettes du fichier d'exemple des cas limites. Les
// lettres décomposées et les blancs autres que l'espace s'écrivent par leur
// point de code.
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import { describe, test } from '../../test/lanceur.js';
import { lireEtiquettes } from '../stockage/etiquettes.js';
import { VALEURS_EXCLU, apercevoir } from './apercu.js';
import { contientNul, decoder } from './encodage.js';
import { cleNormalisee } from './normalisation.js';
const FIXTURES = new URL('../../test/fixtures/csv/', import.meta.url);
const EXEMPLES = new URL('../../exemples/', import.meta.url);
// Texte d'un CSV : chaque ligne donnée, terminée par CRLF.
const csv = (...lignes) => lignes.map((ligne) => `${ligne}\r\n`).join('');
@ -44,7 +48,8 @@ const existant = (id, nom, prenom, appartenance) => ({
// Champs d'une ligne valide, dans l'ordre de l'aperçu ; un champ omis vaut null.
const champs = ({
nom, prenom = null, appartenance = null, courriel = null, titrePressenti = null, notes = null, exclu = null,
}) => ({ nom, prenom, appartenance, courriel, titrePressenti, notes, exclu });
etiquettes = null,
}) => ({ nom, prenom, appartenance, courriel, titrePressenti, notes, exclu, etiquettes });
// Aperçu d'un texte refusé en entier : ce que la lecture a établi avant le
// refus, aucune ligne.
@ -60,6 +65,7 @@ const refusGlobal = (
lignes: [],
refusees: [],
fusions: [],
fusionsEtiquettes: [],
doublons: [],
homonymes: [],
multilignes: [],
@ -122,6 +128,7 @@ describe("apercevoir : les lignes d'un texte sans défaut (§ 10.1)", () => {
],
refusees: [],
fusions: [],
fusionsEtiquettes: [],
doublons: [],
homonymes: [],
multilignes: [],
@ -131,11 +138,13 @@ describe("apercevoir : les lignes d'un texte sans défaut (§ 10.1)", () => {
});
test("les champs d'une ligne, dans l'ordre du participant, quel que soit celui des colonnes", () => {
const [ligne] = apercevoir(csv('notes;exclu;titre;courriel;équipe;prénom;nom', 'n;oui;t;c@exemple.test;a;p;Ombrelle'))
.lignes;
const [ligne] = apercevoir(
csv('mots-clés;notes;exclu;titre;courriel;équipe;prénom;nom', 'e;n;oui;t;c@exemple.test;a;p;Ombrelle'),
).lignes;
assert.equal(
JSON.stringify(ligne.champs),
'{"nom":"Ombrelle","prenom":"p","appartenance":"a","courriel":"c@exemple.test","titrePressenti":"t","notes":"n","exclu":true}',
'{"nom":"Ombrelle","prenom":"p","appartenance":"a","courriel":"c@exemple.test","titrePressenti":"t","notes":"n",' +
'"exclu":true,"etiquettes":["e"]}',
);
});
@ -508,6 +517,222 @@ describe("apercevoir : les orthographes d'une appartenance (§ 10.1, § 14.10)",
});
});
// Les étiquettes de chaque ligne valide, par rang.
const etiquettesDe = (apercu) => apercu.lignes.map(({ ligne, champs: lus }) => [ligne, lus.etiquettes]);
// Une table qui porte un objectif de soirée sur les étiquettes données.
const tableVisant = (id, etiquettes) => ({
id, numero: id, sieges: null, forme: 'ronde', position: { x: 0, y: 0 },
objectifs: [{ portee: 'tous', tour: null, regle: null, etiquettes }],
});
describe('apercevoir : la colonne des étiquettes, lue par la règle de la saisie (§ 10.1, A17)', () => {
test('la cellule se découpe sur « , », « ; », « | » et la fin de ligne ; chaque morceau mis en forme, les vides écartés', () => {
const texte = csv(
'nom;Étiquettes',
'Ombrelle;"finance, Montréal |bénévole;;ressources\u{A0}humaines"',
'Pervenche;" ; , | "',
'Lacasse;',
'Sarcelle;"atelier\r\nmusique"',
);
assert.deepEqual(etiquettesDe(apercevoir(texte)), [
[2, ['finance', 'Montréal', 'bénévole', 'ressources humaines']],
[3, null],
[4, null],
[5, ['atelier', 'musique']],
]);
});
test('sans colonne des étiquettes, chaque ligne lit null ; « e\u{301} » décomposé se lit en NFC', () => {
assert.deepEqual(etiquettesDe(apercevoir(csv('nom', 'Ombrelle'))), [[2, null]]);
assert.deepEqual(etiquettesDe(apercevoir(csv('nom;mots-clés', 'Ombrelle;be\u{301}ne\u{301}vole'))), [[2, ['bénévole']]]);
});
test("deux morceaux de même clé sur une ligne n'en font qu'un, et la fusion se nomme", () => {
const apercu = apercevoir(csv('nom;etiquettes', 'Ombrelle;"Finance, finance ; FINANCE , Montréal"'));
assert.deepEqual(etiquettesDe(apercu), [[2, ['FINANCE', 'Montréal']]]);
assert.deepEqual(apercu.fusionsEtiquettes, [{ retenue: 'FINANCE', orthographes: ['FINANCE', 'Finance', 'finance'], lignes: 1 }]);
});
test("deux orthographes sur deux lignes : une seule étiquette, la plus petite, et le rapport nomme la fusion et son nombre de lignes", () => {
const apercu = apercevoir(csv('nom;etiquettes', 'Ombrelle;finance', 'Pervenche;Finance', 'Lacasse;fínance', 'Sarcelle;Finance'));
assert.deepEqual(etiquettesDe(apercu), [[2, ['Finance']], [3, ['Finance']], [4, ['Finance']], [5, ['Finance']]]);
assert.deepEqual(apercu.fusionsEtiquettes, [{ retenue: 'Finance', orthographes: ['Finance', 'finance', 'fínance'], lignes: 2 }]);
assert.deepEqual(apercu.fusions, [], 'les fusions des appartenances restent à part');
});
test("une étiquette égale au nom d'une appartenance ne se mêle pas à elle", () => {
const apercu = apercevoir(csv('nom;appartenance;etiquettes', 'Ombrelle;finance;Finance', 'Pervenche;FINANCE;Montréal'));
assert.deepEqual(etiquettesDe(apercu), [[2, ['Finance']], [3, ['Montréal']]]);
assert.deepEqual(apercu.fusionsEtiquettes, []);
assert.equal(apercu.fusions.length, 1);
});
test("les étiquettes des personnes existantes donnent l'orthographe, sous ajouter et mettre à jour ; plus sous remplacer", () => {
const participants = [{ ...existant(4, 'Pervenche', 'Théo', null), etiquettes: ['finance'] }];
const texte = csv('nom;etiquettes', 'Ombrelle;Finance');
for (const mode of ['ajouter', 'mettreAJour']) {
const apercu = apercevoir(texte, { participants, mode });
assert.deepEqual(etiquettesDe(apercu), [[2, ['finance']]], mode);
assert.deepEqual(apercu.fusionsEtiquettes, [{ retenue: 'finance', orthographes: ['Finance', 'finance'], lignes: 1 }], mode);
}
const remplace = apercevoir(texte, { participants, mode: 'remplacer' });
assert.deepEqual(etiquettesDe(remplace), [[2, ['Finance']]]);
assert.deepEqual(remplace.fusionsEtiquettes, []);
});
test("les étiquettes des objectifs de table donnent l'orthographe dans les trois modes, remplacer compris", () => {
const sansObjectif = { id: 2, numero: 2, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } };
const tables = [tableVisant(1, ['RH', 'finance']), sansObjectif];
const participants = [{ ...existant(4, 'Pervenche', 'Théo', null), etiquettes: ['Finance'] }];
for (const mode of ['ajouter', 'mettreAJour', 'remplacer']) {
const apercu = apercevoir(csv('nom;etiquettes', 'Ombrelle;FINANCE, rh'), { participants, tables, mode });
// Sous ajouter et mettre à jour, l'événement porte « Finance » et
// « finance » : la plus petite par points de code est reçue.
// Deux fusions de la même ligne se rangent par l'orthographe retenue.
const finance = mode === 'remplacer' ? 'finance' : 'Finance';
assert.deepEqual(etiquettesDe(apercu), [[2, [finance, 'RH']]], mode);
assert.deepEqual(
apercu.fusionsEtiquettes.map(({ retenue, lignes }) => [retenue, lignes]),
mode === 'remplacer' ? [['RH', 1], ['finance', 1]] : [['Finance', 1], ['RH', 1]],
mode,
);
}
});
test("une ligne qui porte déjà l'orthographe retenue ne fait pas de fusion", () => {
const apercu = apercevoir(csv('nom;etiquettes', 'Ombrelle;RH'), { tables: [tableVisant(1, ['RH', 'rh'])] });
assert.deepEqual(etiquettesDe(apercu), [[2, ['RH']]]);
assert.deepEqual(apercu.fusionsEtiquettes, []);
});
test("l'ordre des participants et des tables donnés ne change ni les listes ni les fusions", () => {
const participants = [
{ ...existant(2, 'Pervenche', null, null), etiquettes: ['finance'] },
{ ...existant(1, 'Lacasse', null, null), etiquettes: ['FINANCE'] },
];
const tables = [tableVisant(1, ['Finance']), tableVisant(2, ['bénévole'])];
const texte = csv('nom;etiquettes', 'Ombrelle;fínance, Bénévole');
const un = apercevoir(texte, { participants, tables });
const autre = apercevoir(texte, { participants: [...participants].reverse(), tables: [...tables].reverse() });
assert.deepEqual(autre, un);
assert.deepEqual(etiquettesDe(un), [[2, ['FINANCE', 'bénévole']]]);
});
test('apercevoir ne modifie ni les participants ni les tables reçus', () => {
const participants = [{ ...existant(4, 'Pervenche', null, null), etiquettes: ['finance'] }];
const tables = [tableVisant(1, ['Montréal'])];
const avant = structuredClone({ participants, tables });
apercevoir(csv('nom;etiquettes', 'Ombrelle;Finance, montreal'), { participants, tables });
assert.deepEqual({ participants, tables }, avant);
});
});
describe('apercevoir : une étiquette fautive refuse la ligne, jamais en silence (§ 10.1, A17)', () => {
const quarante = 'é'.repeat(40);
test('40 points de code passent ; 41 refusent la ligne ETIQUETTE_MAL_FORMEE, raison longueur, la valeur nommée', () => {
const apercu = apercevoir(csv('nom;etiquettes', `Ombrelle;${quarante}`, `Pervenche;finance, ${quarante}x`, 'Lacasse;Montréal'));
assert.deepEqual(etiquettesDe(apercu), [[2, [quarante]], [4, ['Montréal']]]);
assert.deepEqual(apercu.refusees, [
{
ligne: 3, code: 'ETIQUETTE_MAL_FORMEE', valeur: `${quarante}x`, raison: 'longueur',
brut: ['Pervenche', `finance, ${quarante}x`],
},
]);
});
test('une tabulation ou un caractère invisible dans une étiquette : ETIQUETTE_MAL_FORMEE, raison controle', () => {
const apercu = apercevoir(csv('nom;etiquettes', 'Ombrelle;fin\tance', 'Pervenche;Mont\u{200B}réal', 'Lacasse;finance'));
assert.deepEqual(
apercu.refusees.map(({ ligne, code, valeur, raison }) => [ligne, code, valeur, raison]),
[
[2, 'ETIQUETTE_MAL_FORMEE', 'fin\tance', 'controle'],
[3, 'ETIQUETTE_MAL_FORMEE', 'Mont\u{200B}réal', 'controle'],
],
);
assert.deepEqual(etiquettesDe(apercu), [[4, ['finance']]]);
});
test('un caractère de contrôle ou invisible au bord de la cellule refuse la ligne, comme lireEtiquettes', () => {
// La cellule entière passe par lireEtiquettes, sans les bords que valeurDe
// retire : la tabulation ou U+FEFF au bord est fautive comme au milieu.
const apercu = apercevoir(csv(
'nom;etiquettes',
'Ombrelle;"RH, finance\u{9}"',
'Pervenche;"finance\u{9}, RH"',
'Lacasse;\u{FEFF}finance',
'Tilleraie;\u{9}',
'Brumeval;finance',
));
assert.deepEqual(
apercu.refusees.map(({ ligne, code, valeur, raison }) => [ligne, code, valeur, raison]),
[
[2, 'ETIQUETTE_MAL_FORMEE', 'finance\u{9}', 'controle'],
[3, 'ETIQUETTE_MAL_FORMEE', 'finance\u{9}', 'controle'],
[4, 'ETIQUETTE_MAL_FORMEE', '\u{FEFF}finance', 'controle'],
[5, 'ETIQUETTE_MAL_FORMEE', '\u{9}', 'controle'],
],
);
for (const { ligne, valeur } of apercu.refusees) {
const cellule = apercu.refusees.find((refus) => refus.ligne === ligne).brut[1];
assert.deepEqual(lireEtiquettes(cellule).fautes[0].valeur, valeur);
}
assert.deepEqual(etiquettesDe(apercu), [[6, ['finance']]]);
});
test('20 étiquettes passent ; une vingt et unième refuse la ligne ETIQUETTES_TROP_NOMBREUSES, le nombre nommé', () => {
const mots = (n) => Array.from({ length: n }, (_, i) => `mot${i + 1}`);
const apercu = apercevoir(csv(
'nom;etiquettes',
`Ombrelle;${mots(20).join(', ')}`,
`Pervenche;${mots(21).join('|')}`,
// Vingt et un morceaux, dont deux de même clé : vingt étiquettes.
`Lacasse;"${[...mots(20), 'MOT1'].join(';')}"`,
));
assert.deepEqual(apercu.refusees, [
{ ligne: 3, code: 'ETIQUETTES_TROP_NOMBREUSES', valeur: null, nombre: 21, brut: ['Pervenche', mots(21).join('|')] },
]);
assert.deepEqual(apercu.lignes.map(({ ligne, champs: lus }) => [ligne, lus.etiquettes.length]), [[2, 20], [4, 20]]);
});
test('un seul motif par ligne : exclu inconnu passe avant les étiquettes, une faute de forme avant le nombre', () => {
const vingtEtUne = Array.from({ length: 21 }, (_, i) => `m${i}`).join(',');
const apercu = apercevoir(csv(
'nom;exclu;etiquettes',
`Ombrelle;peut-être;${'x'.repeat(41)}`,
`Pervenche;;${vingtEtUne},${'x'.repeat(41)}`,
';;fin\tance',
'Lacasse;;finance',
));
assert.deepEqual(apercu.refusees.map(({ ligne, code }) => [ligne, code]), [
[2, 'EXCLU_INCONNU'], [3, 'ETIQUETTE_MAL_FORMEE'], [4, 'NOM_ABSENT'],
]);
});
test("une ligne refusée ne prend part à aucune fusion d'étiquettes", () => {
const apercu = apercevoir(csv('nom;exclu;etiquettes', 'Ombrelle;;finance', 'Pervenche;peut-être;Finance', ';;FINANCE'));
assert.deepEqual(etiquettesDe(apercu), [[2, ['finance']]]);
assert.deepEqual(apercu.fusionsEtiquettes, []);
});
test('toutes les lignes refusées pour leurs étiquettes : AUCUNE_LIGNE_VALIDE, les refus rendus, aucune fusion', () => {
const apercu = apercevoir(csv('nom;etiquettes', `Ombrelle;${'x'.repeat(41)}`));
assert.equal(apercu.refusGlobal, 'AUCUNE_LIGNE_VALIDE');
assert.deepEqual(apercu.refusees.map(({ code }) => code), ['ETIQUETTE_MAL_FORMEE']);
assert.deepEqual(apercu.fusionsEtiquettes, []);
});
test("un fichier séparé par « , » dont la cellule d'étiquettes n'est pas citée : refusé, jamais coupé en silence", () => {
const dansLaFenetre = apercevoir(csv('nom,etiquettes', 'Ombrelle,finance, Montréal', 'Pervenche,bénévole'));
assert.equal(dansLaFenetre.refusGlobal, 'SEPARATEUR_INTROUVABLE');
assert.equal(dansLaFenetre.refusGlobalLigne, 2);
const auDela = apercevoir(csv('nom,etiquettes', ...fenetre((nom) => `${nom},bénévole`), 'Ombrelle,finance, Montréal'));
assert.equal(auDela.separateur, ',');
assert.deepEqual(auDela.refusees.map(({ ligne, code }) => [ligne, code]), [[21, 'CHAMPS_EN_TROP']]);
});
});
describe('apercevoir : les doublons, signalés et jamais fusionnés (§ 10.1)', () => {
test('deux lignes du même triplet normalisé : un doublon, et deux lignes restent deux', () => {
const apercu = apercevoir(csv(
@ -1165,6 +1390,7 @@ const DONNEES = [
'derniere_ligne_sans_fin.csv',
'entete_vide.csv',
'espaces_autour.csv',
'etiquettes_virgule_citee.csv',
'ligne_vide_au_milieu.csv',
'marque_crlf.csv',
'utf16le_tabulation.csv',
@ -1261,6 +1487,22 @@ describe("apercevoir : les données d'épreuve, un CSV tel qu'un tableur l'écri
]);
});
test("un fichier séparé par « , » dont la cellule d'étiquettes est citée : chaque étiquette entière, la virgule de la cellule lue comme séparateur d'étiquettes", () => {
const apercu = apercuDe('etiquettes_virgule_citee.csv');
assert.equal(apercu.separateur, ',');
assert.deepEqual(apercu.entetes, ['nom', 'prenom', 'etiquettes']);
assert.deepEqual(lignesDe(apercu), [
[2, champs({ nom: 'Ombrelle', prenom: 'Iris', etiquettes: ['finance', 'Montréal'] })],
[3, champs({ nom: 'Pervenche', prenom: 'Théo', etiquettes: ['bénévole', 'ressources humaines'] })],
[4, champs({ nom: 'Lacasse', prenom: 'Ondine' })],
[5, champs({ nom: 'Sarcelle', prenom: 'Anouk', etiquettes: ['finance'] })],
]);
assert.deepEqual(apercu.lignes[0].brut, ['Ombrelle', 'Iris', 'finance, Montréal']);
assert.deepEqual(apercu.lignes[3].brut, ['Sarcelle', 'Anouk', 'fínance']);
assert.deepEqual(apercu.fusionsEtiquettes, [{ retenue: 'finance', orthographes: ['finance', 'fínance'], lignes: 1 }]);
assert.deepEqual(apercu.refusees, []);
});
test("une colonne d'en-tête vide : non reconnue, sa valeur ignorée, la ligne importée", () => {
const apercu = apercuDe('entete_vide.csv');
assert.deepEqual(apercu.association, {
@ -1279,3 +1521,21 @@ describe("apercevoir : les données d'épreuve, un CSV tel qu'un tableur l'écri
assert.deepEqual(apercu.refusees, []);
});
});
describe("apercevoir : la colonne Étiquettes du fichier d'exemple des cas limites (§ 10.3, A19)", () => {
test('dans ses deux encodages : une cellule citée à « , », une à « | », deux orthographes fondues, une vide, la colonne non reconnue en dernier', () => {
for (const nom of ['participants_cas_limites.csv', 'participants_cas_limites_cp1252.csv']) {
const apercu = apercevoir(decoder(readFileSync(new URL(nom, EXEMPLES))).texte);
const rang = apercu.association.colonnes.etiquettes;
assert.equal(apercu.entetes[rang], 'Étiquettes', nom);
assert.deepEqual(apercu.association.nonReconnues, [apercu.entetes.length - 1], nom);
const cellules = apercu.lignes.map(({ brut }) => brut[rang]);
assert.ok(cellules.some((cellule) => cellule.includes(',')), `${nom} : une cellule à « , »`);
assert.ok(cellules.some((cellule) => cellule.includes('|')), `${nom} : une cellule à « | »`);
assert.ok(cellules.some((cellule) => cellule === ''), `${nom} : une cellule vide`);
assert.ok(apercu.lignes.some(({ champs: lus }) => lus.etiquettes?.length >= 2), nom);
assert.ok(apercu.fusionsEtiquettes.some(({ orthographes }) => orthographes.length >= 2), `${nom} : une fusion`);
assert.deepEqual(apercu.refusees.map(({ code }) => code), ['NOM_ABSENT'], nom);
}
});
});

View file

@ -29,6 +29,9 @@ export const ENTETES_DES_CHAMPS = Object.freeze([
champ('titre_pressenti', ['titre_pressenti', 'titre', 'rôle']),
champ('exclu', ['exclu']),
champ('notes', ['notes']),
// En dernier : l'export l'écrit après les sept champs d'avant les
// étiquettes, et le collage en bloc les lit à la huitième colonne (§ 10.2).
champ('etiquettes', ['etiquettes', 'étiquette', 'mots-clés']),
]);
/** Champs d'un participant que porte un CSV, dans l'ordre de l'export. */

View file

@ -8,10 +8,13 @@
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { CHAMPS, ENTETES_DES_CHAMPS, associer, reconnaitre } from './colonnes.js';
import { cleEntete } from './normalisation.js';
describe('CHAMPS', () => {
test('les sept champs du § 10.1, dans leur ordre, figés', () => {
assert.deepEqual(CHAMPS, ['nom', 'prenom', 'appartenance', 'courriel', 'titre_pressenti', 'exclu', 'notes']);
test('les huit champs du § 10.1, dans leur ordre, etiquettes en dernier, figés', () => {
assert.deepEqual(CHAMPS, [
'nom', 'prenom', 'appartenance', 'courriel', 'titre_pressenti', 'exclu', 'notes', 'etiquettes',
]);
assert.ok(Object.isFrozen(CHAMPS));
});
});
@ -28,6 +31,7 @@ describe('ENTETES_DES_CHAMPS : la table des colonnes reconnues, seule source (§
['titre_pressenti', ['titre_pressenti', 'titre', 'rôle']],
['exclu', ['exclu']],
['notes', ['notes']],
['etiquettes', ['etiquettes', 'étiquette', 'mots-clés']],
],
);
assert.deepEqual(ENTETES_DES_CHAMPS.map(([champ]) => champ), [...CHAMPS]);
@ -47,9 +51,15 @@ describe('ENTETES_DES_CHAMPS : la table des colonnes reconnues, seule source (§
test('chaque en-tête de la table est reconnu sous son champ : la reconnaissance la lit', () => {
const paires = ENTETES_DES_CHAMPS.flatMap(([champ, entetes]) => entetes.map((entete) => [entete, champ]));
assert.equal(paires.length, 12);
assert.equal(paires.length, 15);
for (const [entete, champ] of paires) assert.equal(reconnaitre(entete), champ, entete);
});
test('aucune clé d’en-tête n’est celle de deux en-têtes : un synonyme ne vole la colonne d’aucun autre champ', () => {
const cles = ENTETES_DES_CHAMPS.flatMap(([, entetes]) => entetes.map((entete) => cleEntete(entete)));
assert.equal(cles.length, 15);
assert.equal(new Set(cles).size, cles.length);
});
});
describe('reconnaitre (§ 10.1)', () => {
@ -58,13 +68,21 @@ describe('reconnaitre (§ 10.1)', () => {
for (const champ of CHAMPS) assert.equal(reconnaitre(champ), champ);
});
test('les synonymes : organisation, entreprise et équipe ; titre et rôle', () => {
test('les synonymes : organisation, entreprise et équipe ; titre et rôle ; étiquette et mots-clés', () => {
assert.deepEqual(
['organisation', 'entreprise', 'équipe', 'titre', 'rôle'].map((entete) => reconnaitre(entete)),
['appartenance', 'appartenance', 'appartenance', 'titre_pressenti', 'titre_pressenti'],
['organisation', 'entreprise', 'équipe', 'titre', 'rôle', 'étiquette', 'mots-clés'].map((entete) => reconnaitre(entete)),
['appartenance', 'appartenance', 'appartenance', 'titre_pressenti', 'titre_pressenti', 'etiquettes', 'etiquettes'],
);
});
test('Étiquettes, ETIQUETTE, Mots clés et mots_clés désignent les étiquettes ; « mot-clé » et « tags » restent inconnus', () => {
assert.deepEqual(
['Étiquettes', 'ETIQUETTE', 'Mots clés', 'mots_clés', 'Mots-Cles'].map((entete) => reconnaitre(entete)),
['etiquettes', 'etiquettes', 'etiquettes', 'etiquettes', 'etiquettes'],
);
assert.deepEqual(['mot-clé', 'tags', 'étiquettes 2'].map((entete) => reconnaitre(entete)), [null, null, null]);
});
test('sans égard à la casse, aux accents ni aux espaces', () => {
const cas = [
['NOM', 'nom'],
@ -101,8 +119,8 @@ describe('reconnaitre (§ 10.1)', () => {
describe('associer : par en-tête, jamais par position (§ 10.1)', () => {
test('chaque champ reconnu reçoit le rang de sa colonne, à partir de 0', () => {
assert.deepEqual(associer(['nom', 'prenom', 'appartenance', 'courriel', 'titre_pressenti', 'exclu', 'notes']), {
colonnes: { nom: 0, prenom: 1, appartenance: 2, courriel: 3, titre_pressenti: 4, exclu: 5, notes: 6 },
assert.deepEqual(associer(['nom', 'prenom', 'appartenance', 'courriel', 'titre_pressenti', 'exclu', 'notes', 'etiquettes']), {
colonnes: { nom: 0, prenom: 1, appartenance: 2, courriel: 3, titre_pressenti: 4, exclu: 5, notes: 6, etiquettes: 7 },
ambigus: [],
nonReconnues: [],
});
@ -183,6 +201,14 @@ describe('associer : par en-tête, jamais par position (§ 10.1)', () => {
for (const un of choix) assert.deepEqual(associer(entetes, un), sansChoix, JSON.stringify(un));
});
test("étiquette et mots-clés ensemble : ni l'une ni l'autre, une ambiguïté sur etiquettes", () => {
assert.deepEqual(associer(['nom', 'étiquette', 'mots-clés']), {
colonnes: { nom: 0 },
ambigus: [{ champ: 'etiquettes', rangs: [1, 2] }],
nonReconnues: [],
});
});
test("deux colonnes nom : ni l'une ni l'autre", () => {
assert.deepEqual(associer(['Nom', 'prenom', 'NOM']), {
colonnes: { prenom: 1 },

View file

@ -72,13 +72,13 @@ export function orthographeRecue(existantes, saisie) {
*/
/**
* Réconcilie les étiquettes des lignes d'un import (§ 10.1) avec celles de
* l'événement, puis entre elles. Une clé que l'événement porte reçoit la plus
* petite de ses orthographes existantes ; une clé que seules les lignes
* portent, la plus petite des orthographes lues. Chaque liste est réécrite
* dans son ordre, une étiquette prenant l'orthographe de sa clé ; une
* orthographe qui en rejoint une autre de la même liste se fond à la place
* de la première. Les existantes ne sont jamais réécrites.
* Réconcilie les étiquettes des lignes d'un fichier importé (§ 10.1) avec
* celles de l'événement, puis entre elles. Une clé que l'événement porte
* reçoit la plus petite de ses orthographes existantes ; une clé que seules
* les lignes portent, la plus petite des orthographes lues. Chaque liste est
* réécrite dans son ordre, une étiquette prenant l'orthographe de sa clé ;
* une orthographe qui en rejoint une autre de la même liste se fond à la
* place de la première. Les existantes ne sont jamais réécrites.
*
* Une fusion est rendue pour chaque clé qui réécrit au moins une ligne, et
* aucune ligne n'est réécrite sans la sienne : une ligne qui porte déjà

View file

@ -19,6 +19,14 @@ import { CHAMPS } from './colonnes.js';
/** Séparateur de l'export : celui qu'un tableur francophone rouvre sans manipulation (§ 10.3). */
export const SEPARATEUR = ';';
/**
* Jointure des étiquettes d'une cellule (§ 10.2) : une virgule, séparateur
* que la découpe de la saisie lit (SEPARATEURS_ETIQUETTES), suivie d'une
* espace, que la mise en forme retire. Une étiquette ne porte aucun
* séparateur : la cellule se relit en les mêmes étiquettes.
*/
export const JOINTURE_ETIQUETTES = ', ';
const MARQUE_ORDRE_OCTETS = '\u{FEFF}';
const FIN_DE_LIGNE = '\r\n';
const GUILLEMET = '"';
@ -37,6 +45,7 @@ const PROPRIETE_DU_CHAMP = new Map([
['titre_pressenti', 'titrePressenti'],
['exclu', 'exclu'],
['notes', 'notes'],
['etiquettes', 'etiquettes'],
]);
// Un champ s'écrit cité (RFC 4180) quand il porte le séparateur, un retour
@ -69,12 +78,14 @@ export function ecrireCsv(enregistrements, separateur) {
}
// Cellule d'un champ de CHAMPS pour le participant de rang donné, à partir
// de 0 : exclu en oui ou non, un texte tel quel, null vide. Lèvent TypeError
// un exclu qui n'est pas un booléen, un nom qui n'est pas une chaîne, un
// autre texte ni chaîne ni null. Écrit sans bruit « non » ou vide, un exclu
// de travers réinviterait au réimport une personne qui s'est désistée ; un
// texte d'un autre genre lèverait plus loin, dans l'écriture, sans nommer ni
// la personne ni le champ.
// de 0 : exclu en oui ou non, les étiquettes jointes par JOINTURE_ETIQUETTES
// dans l'ordre de leur liste, vide quand la clé manque, un texte tel quel,
// null vide. Lèvent TypeError un exclu qui n'est pas un booléen, des
// étiquettes présentes qui ne sont pas une liste de textes, un nom qui n'est
// pas une chaîne, un autre texte ni chaîne ni null. Écrit sans bruit « non »
// ou vide, un exclu de travers réinviterait au réimport une personne qui
// s'est désistée ; un texte d'un autre genre lèverait plus loin, dans
// l'écriture, sans nommer ni la personne ni le champ.
function cellule(participant, champ, rang) {
const valeur = participant[PROPRIETE_DU_CHAMP.get(champ)];
const refuser = (attendu) => {
@ -85,6 +96,11 @@ function cellule(participant, champ, rang) {
if (typeof valeur !== 'boolean') refuser('booléen');
return valeur ? OUI : NON;
}
if (champ === 'etiquettes') {
if (!Object.hasOwn(participant, 'etiquettes')) return '';
if (!Array.isArray(valeur) || !valeur.every((motCle) => typeof motCle === 'string')) refuser('liste de textes');
return valeur.join(JOINTURE_ETIQUETTES);
}
if (valeur === null && champ !== 'nom') return '';
if (typeof valeur !== 'string') refuser(champ === 'nom' ? 'texte' : 'texte ou null');
return valeur;
@ -93,17 +109,19 @@ function cellule(participant, champ, rang) {
/**
* La liste des participants en CSV, dans la forme exacte que l'import lit
* (§ 10.2) : UTF-8 avec marque, « ; », CRLF ; l'en-tête
* nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes, les champs
* de CHAMPS dans leur ordre ; puis un participant par ligne, dans l'ordre de
* la liste reçue, que la charge range par identifiant. exclu s'écrit oui ou
* non, un champ null vide, un champ qui porte « ; », un guillemet ou une fin
* de ligne entre guillemets. L'identifiant ne s'écrit pas : le réimport en
* nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes;etiquettes,
* les champs de CHAMPS dans leur ordre ; puis un participant par ligne, dans
* l'ordre de la liste reçue, que la charge range par identifiant. exclu
* s'écrit oui ou non, un champ null vide, la colonne etiquettes toujours,
* ses étiquettes jointes par « , », vide sans étiquette ; un champ qui porte
* « ; », un guillemet ou une fin de ligne entre guillemets. L'identifiant ne s'écrit pas : le réimport en
* attribue de neufs. La liste reçue n'est pas modifiée.
*
* @param {import('../stockage/types.js').Participant[]} participants
* @returns {Uint8Array}
* @throws {TypeError} pour un exclu qui n'est pas un booléen, un nom qui
* n'est pas une chaîne, un autre texte ni chaîne ni null
* @throws {TypeError} pour un exclu qui n'est pas un booléen, des étiquettes
* présentes qui ne sont pas une liste de textes, un nom qui n'est pas une
* chaîne, un autre texte ni chaîne ni null
*/
export function exporterParticipants(participants) {
const lignes = participants.map((participant, rang) => CHAMPS.map((champ) => cellule(participant, champ, rang)));

View file

@ -2,24 +2,28 @@
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de l'export de la liste (§ 10.2, § 10.3) : la forme exacte que
// l'import lit — marque UTF-8, « ; », CRLF, les sept en-têtes de l'import —,
// les champs cités au besoin, et l'aller-retour qui importe, exporte,
// réimporte et compare champ par champ, sur chaque donnée d'épreuve de
// test/fixtures/csv/ et sur une liste aux valeurs difficiles. L'écriture d'un
// l'import lit — marque UTF-8, « ; », CRLF, les huit en-têtes de l'import —,
// les champs cités au besoin, les étiquettes jointes par « , » et relues par
// la découpe de la saisie, et l'aller-retour qui importe, exporte, réimporte
// et compare champ par champ, sur chaque donnée d'épreuve de
// test/fixtures/csv/, sur les fichiers d'exemple et sur une liste aux valeurs
// difficiles. L'écriture d'un
// CSV, que l'export partage avec le CSV des refus, s'éprouve contre la
// découpe qui la relit.
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import { describe, test } from '../../test/lanceur.js';
import { creerCharge } from '../stockage/document.js';
import { SEPARATEURS_ETIQUETTES, lireEtiquettes } from '../stockage/etiquettes.js';
import { VALEURS_EXCLU, apercevoir } from './apercu.js';
import { CHAMPS } from './colonnes.js';
import { decoder } from './encodage.js';
import { SEPARATEUR, ecrireCsv, exporterParticipants } from './export.js';
import { JOINTURE_ETIQUETTES, SEPARATEUR, ecrireCsv, exporterParticipants } from './export.js';
import { appliquerImport } from './import.js';
import { decouper } from './lecture.js';
const FIXTURES = new URL('../../test/fixtures/csv/', import.meta.url);
const EXEMPLES = new URL('../../exemples/', import.meta.url);
const MARQUE = '\u{FEFF}';
@ -35,15 +39,17 @@ const personne = (id, nom, autres = {}) => ({
});
// Champs d'un participant, dans l'ordre du fichier d'état.
const CHAMPS_DU_PARTICIPANT = ['id', 'nom', 'prenom', 'appartenance', 'courriel', 'titrePressenti', 'notes', 'exclu'];
const CHAMPS_DU_PARTICIPANT = [
'id', 'nom', 'prenom', 'appartenance', 'courriel', 'titrePressenti', 'notes', 'exclu', 'etiquettes',
];
// Participants qu'un import crée, en ajoutant, sur une charge neuve, à
// partir des octets d'un fichier ; l'aperçu ne doit refuser ni le texte ni
// aucune ligne.
function importer(octets, nom) {
// aucune ligne, sauf celles que refusesAdmis nomme par leur code.
function importer(octets, nom, refusesAdmis = []) {
const apercu = apercevoir(decoder(octets).texte);
assert.equal(apercu.refusGlobal, null, nom);
assert.deepEqual(apercu.refusees, [], nom);
assert.deepEqual(apercu.refusees.map(({ code }) => code), refusesAdmis, nom);
const charge = creerCharge({ id: 'evt-essai', nom: "Soirée d'essai", siegesParDefaut: 4, tours: 2 });
return appliquerImport(charge, apercu, 'ajouter').charge.participants;
}
@ -60,7 +66,7 @@ function memesParticipants(obtenus, attendus, nom) {
}
describe("exporterParticipants : la forme exacte que l'import lit (§ 10.2)", () => {
test("marque UTF-8, « ; », CRLF, les sept en-têtes de l'import dans leur ordre, un participant par ligne", () => {
test("marque UTF-8, « ; », CRLF, les huit en-têtes de l'import dans leur ordre, un participant par ligne", () => {
const octets = exporterParticipants([
personne(1, 'Ombrelle', {
prenom: 'Iris', appartenance: 'Club des Merles', courriel: 'iris@exemple.test', titrePressenti: 'animation',
@ -73,16 +79,16 @@ describe("exporterParticipants : la forme exacte que l'import lit (§ 10.2)", ()
texteDe(octets),
MARQUE +
csv(
'nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes',
'Ombrelle;Iris;Club des Merles;iris@exemple.test;animation;non;',
'Pervenche;Théo;;;;oui;arrive tard',
'nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes;etiquettes',
'Ombrelle;Iris;Club des Merles;iris@exemple.test;animation;non;;',
'Pervenche;Théo;;;;oui;arrive tard;',
),
);
});
test("les en-têtes sont les champs de l'import, CHAMPS, et le séparateur « ; »", () => {
assert.equal(SEPARATEUR, ';');
assert.equal(CHAMPS.join(';'), 'nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes');
assert.equal(CHAMPS.join(';'), 'nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes;etiquettes');
assert.equal(texteDe(exporterParticipants([])), `${MARQUE}${CHAMPS.join(SEPARATEUR)}\r\n`);
});
@ -90,7 +96,7 @@ describe("exporterParticipants : la forme exacte que l'import lit (§ 10.2)", ()
assert.equal(VALEURS_EXCLU.get('oui'), true);
assert.equal(VALEURS_EXCLU.get('non'), false);
const texte = texteDe(exporterParticipants([personne(1, 'Ombrelle', { exclu: true }), personne(2, 'Pervenche')]));
assert.equal(texte, MARQUE + csv(CHAMPS.join(';'), 'Ombrelle;;;;;oui;', 'Pervenche;;;;;non;'));
assert.equal(texte, MARQUE + csv(CHAMPS.join(';'), 'Ombrelle;;;;;oui;;', 'Pervenche;;;;;non;;'));
});
test('un champ qui porte « ; », un guillemet ou une fin de ligne s\'écrit cité, ses guillemets doublés', () => {
@ -104,20 +110,20 @@ describe("exporterParticipants : la forme exacte que l'import lit (§ 10.2)", ()
MARQUE +
csv(
CHAMPS.join(';'),
'Ombrelle;;"Club ""Les Merles"" ; nord";;;non;"premier\r\nsecond"',
'"""Pervenche""";;;"theo\r@exemple.test";;non;"a\nb"',
'Sarcelle;;;;;non;virgule, tabulation\tseules',
'Ombrelle;;"Club ""Les Merles"" ; nord";;;non;"premier\r\nsecond";',
'"""Pervenche""";;;"theo\r@exemple.test";;non;"a\nb";',
'Sarcelle;;;;;non;virgule, tabulation\tseules;',
),
);
});
test("les lignes suivent l'ordre de la liste reçue", () => {
const texte = texteDe(exporterParticipants([personne(7, 'Sarcelle'), personne(2, 'Ombrelle')]));
assert.equal(texte, MARQUE + csv(CHAMPS.join(';'), 'Sarcelle;;;;;non;', 'Ombrelle;;;;;non;'));
assert.equal(texte, MARQUE + csv(CHAMPS.join(';'), 'Sarcelle;;;;;non;;', 'Ombrelle;;;;;non;;'));
});
test("exporterParticipants ne modifie pas la liste reçue", () => {
const participants = [personne(1, 'Ombrelle', { prenom: 'Iris', notes: 'a;b', exclu: true })];
const participants = [personne(1, 'Ombrelle', { prenom: 'Iris', notes: 'a;b', exclu: true, etiquettes: ['b', 'a'] })];
const avant = structuredClone(participants);
exporterParticipants(participants);
assert.deepEqual(participants, avant);
@ -131,6 +137,37 @@ describe("exporterParticipants : la forme exacte que l'import lit (§ 10.2)", ()
refuse(personne(2, 'Ombrelle', { courriel: 5 }), 'participant 2 de la liste, courriel : texte ou null attendu, reçu 5');
refuse(personne(2, undefined), 'participant 2 de la liste, nom : texte attendu, reçu undefined');
refuse(personne(2, null), 'participant 2 de la liste, nom : texte attendu, reçu null');
refuse(personne(2, 'Ombrelle', { etiquettes: 'finance' }), 'participant 2 de la liste, etiquettes : liste de textes attendu, reçu "finance"');
refuse(personne(2, 'Ombrelle', { etiquettes: null }), 'participant 2 de la liste, etiquettes : liste de textes attendu, reçu null');
refuse(personne(2, 'Ombrelle', { etiquettes: ['finance', 3] }), 'participant 2 de la liste, etiquettes : liste de textes attendu, reçu ["finance",3]');
});
});
describe("exporterParticipants : la colonne des étiquettes (§ 10.2, A18)", () => {
test("toujours écrite, en dernier : les étiquettes jointes par « , », dans l'ordre de la liste ; sans étiquette, vide", () => {
assert.equal(JOINTURE_ETIQUETTES, ', ');
const texte = texteDe(exporterParticipants([
personne(1, 'Ombrelle', { etiquettes: ['Montréal', 'finance'] }),
personne(2, 'Pervenche'),
personne(3, 'Lacasse', { etiquettes: ['bénévole'] }),
]));
assert.equal(
texte,
MARQUE + csv(CHAMPS.join(';'), 'Ombrelle;;;;;non;;Montréal, finance', 'Pervenche;;;;;non;;', 'Lacasse;;;;;non;;bénévole'),
);
});
test('la jointure, privée de ses blancs, est un séparateur que la découpe de la saisie lit', () => {
assert.ok(SEPARATEURS_ETIQUETTES.includes(JOINTURE_ETIQUETTES.trim()));
const etiquettes = ['Montréal', 'finance', 'ressources humaines'];
assert.deepEqual(lireEtiquettes(etiquettes.join(JOINTURE_ETIQUETTES)), { etiquettes, fautes: [] });
});
test("une étiquette qui commence par « = » s'exporte telle quelle : aucune neutralisation de formule", () => {
const texte = texteDe(exporterParticipants([personne(1, 'Ombrelle', { etiquettes: ['=SOMME(A1)', '+33', '@arobase'] })]));
assert.equal(texte, MARQUE + csv(CHAMPS.join(';'), 'Ombrelle;;;;;non;;=SOMME(A1), +33, @arobase'));
const [personneRelue] = importer(new TextEncoder().encode(texte), 'formule');
assert.deepEqual(personneRelue.etiquettes, ['+33', '=SOMME(A1)', '@arobase']);
});
});
@ -138,6 +175,7 @@ describe("exporterParticipants : l'aller-retour, champ par champ (§ 10.2)", ()
test("chaque donnée d'épreuve de test/fixtures/csv/ : importée, exportée, réimportée, les mêmes participants", () => {
const donnees = readdirSync(FIXTURES).sort();
assert.ok(donnees.length > 0, "aucune donnée d'épreuve");
assert.ok(donnees.includes('etiquettes_virgule_citee.csv'));
for (const nom of donnees) {
const importes = importer(readFileSync(new URL(nom, FIXTURES)), nom);
assert.ok(importes.length > 0, nom);
@ -147,6 +185,23 @@ describe("exporterParticipants : l'aller-retour, champ par champ (§ 10.2)", ()
}
});
test("chaque fichier d'exemple : importé, exporté, réimporté, les mêmes participants, étiquettes comprises", () => {
const exemples = [
['participants_cas_limites.csv', ['NOM_ABSENT']],
['participants_cas_limites_cp1252.csv', ['NOM_ABSENT']],
['participants_demo_petite.csv', []],
['participants_demo_grande.csv', []],
];
let etiquetees = 0;
for (const [nom, refusesAdmis] of exemples) {
const importes = importer(readFileSync(new URL(nom, EXEMPLES)), nom, refusesAdmis);
assert.ok(importes.length > 0, nom);
etiquetees += importes.filter(({ etiquettes }) => etiquettes !== undefined).length;
memesParticipants(importer(exporterParticipants(importes), nom), importes, nom);
}
assert.ok(etiquetees > 0, 'aucune personne étiquetée dans les exemples');
});
test('une liste aux valeurs difficiles : guillemets, séparateurs, fins de ligne, formules, accents, exclusions', () => {
const difficiles = [
personne(1, 'Ombrelle', {
@ -157,6 +212,8 @@ describe("exporterParticipants : l'aller-retour, champ par champ (§ 10.2)", ()
personne(3, '=SOMME(A1:A3)', { prenom: '+33', appartenance: '-moins', courriel: '@arobase' }),
personne(4, 'Sarcelle', { prenom: 'Ondine', appartenance: 'Chœur de l\u{2019}Anse', notes: 'saut\nseul\rretour seul' }),
personne(5, 'Bruyère', { titrePressenti: 'fin"', notes: '"' }),
personne(6, 'Lacasse', { etiquettes: ['=SOMME(A1)', 'Montréal', 'finance', 'ressources humaines', 'é'] }),
personne(7, 'Pervenche', { etiquettes: Array.from({ length: 20 }, (_, i) => `m${String(i).padStart(2, '0')}`) }),
];
const octets = exporterParticipants(difficiles);
assert.equal(apercevoir(decoder(octets).texte).separateur, ';');

View file

@ -28,7 +28,7 @@ import { enregistrementVide } from './lecture.js';
// Champs d'une ligne de l'aperçu, dans l'ordre du participant : ceux que
// porte une personne créée, et que pose une mise à jour quand la cellule
// n'est pas vide.
// n'est pas vide. Les étiquettes, une liste, se posent à part (poser).
const CHAMPS_LUS = Object.freeze(['nom', 'prenom', 'appartenance', 'courriel', 'titrePressenti', 'notes', 'exclu']);
// En-têtes des deux colonnes qu'ajoute le CSV des refus, inconnues de
@ -42,11 +42,17 @@ const ENTETE_MOTIF = 'motif';
*
* @typedef {Object} LigneRefusee
* @property {number} ligne rang de l'enregistrement, en-tête compris
* @property {string} code CHAMPS_EN_TROP, NOM_ABSENT ou
* EXCLU_INCONNU, de l'aperçu ; PLUSIEURS_CORRESPONDENT ou
* DEJA_DESIGNE, de la mise à jour
* @property {string} code CHAMPS_EN_TROP, NOM_ABSENT,
* EXCLU_INCONNU, ETIQUETTE_MAL_FORMEE ou
* ETIQUETTES_TROP_NOMBREUSES, de l'aperçu ; PLUSIEURS_CORRESPONDENT
* ou DEJA_DESIGNE, de la mise à jour
* @property {string|null} valeur la valeur d'« exclu » refusée sous
* EXCLU_INCONNU, null sous tout autre code
* EXCLU_INCONNU, l'étiquette fautive sous ETIQUETTE_MAL_FORMEE,
* null sous tout autre code
* @property {string} [raison] ETIQUETTE_MAL_FORMEE : 'controle' ou
* 'longueur'
* @property {number} [nombre] ETIQUETTES_TROP_NOMBREUSES : le nombre
* d'étiquettes de la cellule
* @property {number[]} [participants] refus de la mise à jour : les
* identifiants croissants que la ligne désigne
* @property {number} [premiereLigne] DEJA_DESIGNE : la ligne qui désigne
@ -214,14 +220,30 @@ export function bilanRemplacement(charge) {
};
}
// Les étiquettes d'une ligne telles que le fichier d'état les range : une
// copie, triée par unités de code (§ 8.9), qui ne partage rien avec
// l'aperçu.
const rangees = (etiquettes) => [...etiquettes].sort(comparerTextes);
// Vrai quand deux listes d'étiquettes rangées portent les mêmes chaînes, rang
// à rang : deux listes distinctes de même contenu sont égales.
const memesEtiquettes = (a, b) => a.length === b.length && a.every((motCle, rang) => motCle === b[rang]);
// Participant créé d'une ligne de l'aperçu, ses clés dans l'ordre du
// fichier d'état : un exclu vide vaut non.
const creer = (id, { nom, prenom, appartenance, courriel, titrePressenti, notes, exclu }) => ({
id, nom, prenom, appartenance, courriel, titrePressenti, notes, exclu: exclu ?? false,
});
// fichier d'état : un exclu vide vaut non ; les étiquettes, rangées, en
// dernier, la clé absente quand la ligne n'en porte pas (§ 8.9).
function creer(id, { nom, prenom, appartenance, courriel, titrePressenti, notes, exclu, etiquettes }) {
const participant = { id, nom, prenom, appartenance, courriel, titrePressenti, notes, exclu: exclu ?? false };
if (etiquettes !== null) participant.etiquettes = rangees(etiquettes);
return participant;
}
// Pose sur le participant chaque champ non vide de la ligne : un champ vide
// garde la valeur (§ 10.1). Rend vrai quand une valeur a changé.
// garde la valeur (§ 10.1). Une cellule d'étiquettes non vide remplace la
// liste entière, une cellule vide la garde : un CSV n'efface pas les
// étiquettes. Les listes se comparent étiquette par étiquette, une fois
// rangées : un fichier réimporté ne compte aucun modifié. Rend vrai quand
// une valeur a changé.
function poser(participant, champs) {
let change = false;
for (const champ of CHAMPS_LUS) {
@ -230,6 +252,13 @@ function poser(participant, champs) {
change = true;
}
}
if (champs.etiquettes !== null) {
const etiquettes = rangees(champs.etiquettes);
if (!memesEtiquettes(etiquettes, participant.etiquettes ?? [])) {
participant.etiquettes = etiquettes;
change = true;
}
}
return change;
}
@ -288,17 +317,19 @@ function mettreAJour(participants, lignes) {
* Une ligne ajoutée à côté d'un participant de même nom est celle que
* l'aperçu nomme dans homonymes. Désigné seul, le participant prend chaque champ non vide de
* la ligne, nom, prénom et appartenance compris, et garde la valeur d'un
* champ vide ; exclu vide le garde. Une ligne qui ne désigne personne est
* champ vide ; exclu vide le garde ; une cellule d'étiquettes non vide
* remplace la liste, vide la garde. Une ligne qui ne désigne personne est
* ajoutée ; qui en désigne plusieurs, refusée PLUSIEURS_CORRESPONDENT ; qui
* désigne un participant qu'une ligne précédente désigne déjà, refusée
* DEJA_DESIGNE ;
* - 'remplacer' : les participants et leurs réservations sont retirés,
* puis chaque ligne valide ajoutée. Tables, titres, propositions et retenu
* restent (bilanRemplacement).
* Une personne créée prend les champs de sa ligne, exclu vide valant non ;
* les identifiants partent de prochainsIds.participant, dans l'ordre du
* fichier, et le compteur avance d'autant. La charge et l'aperçu reçus ne
* sont pas modifiés ; la charge rendue n'en partage aucun objet.
* Une personne créée prend les champs de sa ligne, exclu vide valant non,
* ses étiquettes rangées par unités de code, absentes quand la ligne n'en
* porte pas ; les identifiants partent de prochainsIds.participant, dans
* l'ordre du fichier, et le compteur avance d'autant. La charge et l'aperçu
* reçus ne sont pas modifiés ; la charge rendue n'en partage aucun objet.
*
* Le résumé (ResumeImport) compte les participants ajoutés, mis à jour,
* inchangés et retirés, et les réservations retirées. refusees porte les

View file

@ -5,7 +5,9 @@
// remplacer, et ce que remplacer annonce avant d'agir ; l'import selon l'état
// du plan ; les refus globaux ; les identifiants jamais réutilisés ; le CSV
// des lignes refusées, sa forme, et la boucle qui le corrige et le réimporte
// tel quel. Les marques combinantes s'écrivent par leur point de code.
// tel quel ; la colonne des étiquettes, qu'une personne créée porte rangée
// et qu'une mise à jour remplace sans jamais l'effacer. Les marques
// combinantes s'écrivent par leur point de code.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { serialiser } from '../stockage/canonique.js';
@ -14,6 +16,7 @@ import { VERSION } from '../version.genere.js';
import { apercevoir } from './apercu.js';
import { decoder } from './encodage.js';
import { ErreurCsv } from './erreurs.js';
import { exporterParticipants } from './export.js';
import { appliquerImport, bilanRemplacement, exporterRefus, pourvoyeursDesTitres } from './import.js';
import { decouper } from './lecture.js';
@ -130,10 +133,11 @@ function retenuDe({ id, siegesAttribues, tables, capacites, tours, participants,
return { proposition: id, siegesAttribues, tables, capacites, tours, participants, placement };
}
// Aperçu d'un texte contre les participants de la charge, dans le mode
// donné, puis import dans ce même mode.
// Aperçu d'un texte contre les participants et les tables de la charge, dans
// le mode donné, puis import dans ce même mode.
function importer(charge, texte, mode, choix = {}) {
return appliquerImport(charge, apercevoir(texte, { participants: charge.participants, choix, mode }), mode);
const apercu = apercevoir(texte, { participants: charge.participants, tables: charge.tables, choix, mode });
return appliquerImport(charge, apercu, mode);
}
// Motif d'épreuve : le code et ses détails en JSON. Il porte guillemets et
@ -495,6 +499,124 @@ describe('appliquerImport : mettre à jour (§ 10.1)', () => {
});
});
describe('appliquerImport : la colonne des étiquettes (§ 10.1, A17)', () => {
test("une personne créée porte ses étiquettes rangées par unités de code, en dernière clé ; sans étiquette, la clé est absente", () => {
const { charge: apres, resume } = importer(
chargeDe(),
csv('nom;etiquettes', 'Ombrelle;finance, Montréal|bénévole', 'Pervenche;', 'Lacasse;" ; | "'),
'ajouter',
);
assert.deepEqual(apres.participants, [
personne(1, 'Ombrelle', null, null, { etiquettes: ['Montréal', 'bénévole', 'finance'] }),
personne(2, 'Pervenche'),
personne(3, 'Lacasse'),
]);
assert.deepEqual(Object.keys(apres.participants[0]).at(-1), 'etiquettes');
assert.ok(!Object.hasOwn(apres.participants[1], 'etiquettes'));
assert.equal(resume.ajoutes, 3);
// La charge rendue s'enregistre et se relit telle quelle.
assert.deepEqual(analyser(serialiser(apres, { revision: 1, produitVersion: VERSION.affichee })).charge, apres);
});
test("l'orthographe reçue est celle de l'événement : une personne existante, un objectif de table", () => {
const tableVisee = { ...table(2), objectifs: [{ portee: 'tous', tour: null, regle: null, etiquettes: ['RH'] }] };
const charge = chargeDe({
participants: [personne(1, 'Lacasse', null, null, { etiquettes: ['finance'] })],
tables: [table(1), tableVisee],
});
const { charge: apres } = importer(charge, csv('nom;etiquettes', 'Ombrelle;"FINANCE; rh"'), 'ajouter');
assert.deepEqual(apres.participants[1].etiquettes, ['RH', 'finance']);
const { charge: remplacee } = importer(charge, csv('nom;etiquettes', 'Ombrelle;"FINANCE; rh"'), 'remplacer');
assert.deepEqual(remplacee.participants.map(({ etiquettes }) => etiquettes), [['FINANCE', 'RH']]);
assert.deepEqual(remplacee.tables, charge.tables, 'remplacer garde les objectifs des tables');
});
test('en mise à jour, une cellule non vide remplace les étiquettes, une cellule vide les garde', () => {
const charge = chargeDe({
participants: [
personne(1, 'Ombrelle', 'Iris', null, { etiquettes: ['finance', 'Montréal'].sort() }),
personne(2, 'Pervenche', 'Théo', null, { etiquettes: ['bénévole'] }),
personne(3, 'Lacasse', 'Ondine'),
],
});
const { charge: apres, resume } = importer(
charge,
csv('nom;prenom;etiquettes', 'Ombrelle;Iris;ressources humaines', 'Pervenche;Théo;', 'Lacasse;Ondine;" , "'),
'mettreAJour',
);
assert.deepEqual(apres.participants.map(({ etiquettes }) => etiquettes), [['ressources humaines'], ['bénévole'], undefined]);
assert.ok(!Object.hasOwn(apres.participants[2], 'etiquettes'));
assert.deepEqual(resume, resumeDe({ misAJour: 1, inchanges: 2 }));
});
test('en mise à jour, les mêmes étiquettes dans un autre ordre, ou d’une autre orthographe de même clé, ne changent rien', () => {
const existants = [
personne(1, 'Ombrelle', 'Iris', null, { etiquettes: ['Montréal', 'finance'] }),
personne(2, 'Pervenche', 'Théo', null, { etiquettes: ['bénévole'] }),
];
const charge = chargeDe({ participants: existants });
const { charge: apres, resume } = importer(
charge,
csv('nom;prenom;etiquettes', 'Ombrelle;Iris;FINANCE | montréal', 'Pervenche;Théo;Bénévole'),
'mettreAJour',
);
assert.deepEqual(apres.participants, existants);
assert.deepEqual(resume, resumeDe({ inchanges: 2 }));
});
test('en mise à jour, une étiquette de plus, de moins ou autre compte la personne mise à jour', () => {
const charge = chargeDe({
participants: [
personne(1, 'Ombrelle', null, null, { etiquettes: ['Montréal', 'finance'] }),
personne(2, 'Pervenche', null, null, { etiquettes: ['Montréal', 'finance'] }),
personne(3, 'Lacasse', null, null, { etiquettes: ['Montréal', 'finance'] }),
personne(4, 'Sarcelle'),
],
});
const { charge: apres, resume } = importer(
charge,
csv('nom;etiquettes', 'Ombrelle;finance, Montréal, RH', 'Pervenche;finance', 'Lacasse;Montréal, atelier', 'Sarcelle;finance'),
'mettreAJour',
);
assert.deepEqual(apres.participants.map(({ etiquettes }) => etiquettes), [
['Montréal', 'RH', 'finance'], ['finance'], ['Montréal', 'atelier'], ['finance'],
]);
assert.deepEqual(resume, resumeDe({ misAJour: 4 }));
});
test('un export réimporté en mise à jour ne compte aucun modifié', () => {
const existants = [
personne(1, 'Ombrelle', 'Iris', 'Club des Merles', { etiquettes: ['=SOMME(A1)', 'Montréal', 'finance'] }),
personne(2, 'Pervenche', 'Théo', null, { exclu: true }),
];
const charge = chargeDe({ participants: existants });
const { charge: apres, resume } = importer(charge, decoder(exporterParticipants(existants)).texte, 'mettreAJour');
assert.deepEqual(apres.participants, existants);
assert.deepEqual(resume, resumeDe({ inchanges: 2 }));
});
test("les étiquettes rendues ne partagent rien avec l'aperçu : le changer après coup ne touche pas la charge", () => {
const charge = chargeDe({ participants: [personne(1, 'Ombrelle', null, null, { etiquettes: ['finance'] })] });
const apercu = apercevoir(csv('nom;etiquettes', 'Ombrelle;RH', 'Pervenche;atelier'), {
participants: charge.participants, mode: 'mettreAJour',
});
const { charge: apres } = appliquerImport(charge, apercu, 'mettreAJour');
for (const { champs } of apercu.lignes) champs.etiquettes.push('ajout');
assert.deepEqual(apres.participants.map(({ etiquettes }) => etiquettes), [['RH'], ['atelier']]);
});
test("le CSV des refus reprend une ligne refusée pour ses étiquettes, motif compris, et se réimporte tel quel", () => {
const texte = csv('nom;etiquettes', 'Ombrelle;finance', `Pervenche;${'x'.repeat(41)}`);
const apercu = apercevoir(texte);
assert.deepEqual(apercu.refusees.map(({ code }) => code), ['ETIQUETTE_MAL_FORMEE']);
const { texte: refus } = decoder(exporterRefus(apercu, MOTIF));
const { enregistrements } = decouper(refus, ';');
assert.deepEqual(enregistrements[1].slice(0, 3), ['Pervenche', 'x'.repeat(41), '3']);
assert.match(enregistrements[1][3], /^ETIQUETTE_MAL_FORMEE .*"raison":"longueur"/);
assert.deepEqual(apercevoir(refus).refusees.map(({ code }) => code), ['ETIQUETTE_MAL_FORMEE']);
});
});
describe('appliquerImport : remplacer, et ce que remplacer annonce (§ 10.1)', () => {
test("bilanRemplacement compte, avant d'agir, les participants, les réservations, les titres pourvus et les propositions touchées", () => {
const charge = chargeDe({

View file

@ -47,6 +47,7 @@ const SANS_ENTREE = new Map([
['respecterObjectifs', 'case des objectifs, sans entrée à l’écran de la génération'],
['modeObjectifs', 'mode des objectifs, sans entrée à l’écran de la génération'],
['rencontrerAnimateurs', 'case des animateurs, sans entrée à l’écran de la génération'],
['priorites', 'ordre des priorités, écrit par son éditeur de la section Génération, non par une entrée'],
]);
// La commande qui écrit un réglage de chaque section, et ses arguments.

View file

@ -428,8 +428,8 @@ describe('une ligne fausse n’arrête pas le reste (§ 10.1, § 2.1)', () => {
const e = await ouvrir();
const { texte } = decoder(await lireLivre(urlGrande));
const enregistrements = texte.split('\r\n');
expect(enregistrements[212]).toMatch(/;non;$/);
enregistrements[212] = enregistrements[212].replace(/;non;$/, ';peut-être;');
expect(enregistrements[212]).toMatch(/;non;;$/);
enregistrements[212] = enregistrements[212].replace(/;non;;$/, ';peut-être;;');
await choisir(e, 'grande.csv', enUtf8(enregistrements.join('\r\n')));
const apercu = dans(e, '[data-apercu]');
expect(dans(e, '[data-valides] [data-nombre]').textContent).toBe('259');
@ -611,6 +611,23 @@ describe('une seule entrée, des doublons signalés, une mise à jour qui n’ef
expect(new Set(ids).size).toBe(4);
});
test('une étiquette qui ne diffère d’un objectif de table que par la casse : la personne importée reçoit l’orthographe de l’objectif', async () => {
// Aucune personne ne porte l'étiquette : seul l'objectif de la table la
// donne, et l'aperçu de l'écran doit le lire, sans quoi le moteur, qui
// compare les chaînes, ne rapprocherait jamais la personne de l'objectif.
const e = await ouvrir({
prepare: async (seance) => {
await seance.executer('creerTable', { forme: 'ronde' });
const [{ id }] = seance.etat().charge.tables;
await seance.executer('poserObjectif', { table: id, tour: null, etiquettes: 'finance', regle: null });
},
});
await coller(e, 'nom;etiquettes\nOmbrelle;Finance');
choisirMode(e, 'ajouter');
await importer(e);
expect(participantsDe(e).map(({ nom, etiquettes }) => [nom, etiquettes])).toEqual([['Ombrelle', ['finance']]]);
});
test('mettre à jour : aucun courriel vidé ; une ligne qui désigne deux personnes, refusée, les deux nommées ; une seconde ligne pour la même personne, refusée, la première nommée', async () => {
const e = await ouvrir({
participants: [

View file

@ -6,9 +6,11 @@
// 2). Un fichier choisi par le dialogue de la façade, un fichier déposé
// sur la fenêtre, ou un texte que le collage de la grille remet par
// ui.ouvrirImport, ouvre l'aperçu ; rien ne s'importe hors de lui.
// L'aperçu se calcule par apercevoir contre les participants de la
// charge, sous le mode choisi et le choix des colonnes ambiguës : il
// n'écrit rien, et s'ouvre en lecture comme sur un plan bloqué. Seule
// L'aperçu se calcule par apercevoir contre les participants et les
// tables de la charge — les étiquettes des objectifs donnent leur
// orthographe à celles du fichier —, sous le mode choisi et le choix des
// colonnes ambiguës : il n'écrit rien, et s'ouvre en lecture comme sur un
// plan bloqué. Seule
// « Importer » écrit, par seance.executer('importerParticipants', …), et
// son refus se pose à côté d'elle ; « Remplacer » passe d'abord par la
// fenêtre à confirmer, qui porte les quatre comptes de bilanRemplacement,
@ -54,8 +56,9 @@
let enregistre = $state(null);
const participants = $derived($seance.charge?.participants ?? []);
const tables = $derived($seance.charge?.tables ?? []);
const apercu = $derived(
source === null || source.refusDecodage !== null ? null : apercevoir(source.texte, { participants, choix, mode }),
source === null || source.refusDecodage !== null ? null : apercevoir(source.texte, { participants, tables, choix, mode }),
);
const modele = $derived(
source === null ? null : modeleApercu({ encodage: source.encodage, apercu, refusDecodage: source.refusDecodage }),

View file

@ -7,7 +7,7 @@
// module ne choisit que les clés, et ne recalcule rien de ce que l'aperçu de
// src/csv établit : association, lignes, fusions et doublons s'y lisent tels
// quels.
import { CHAMPS } from '../../application/csv.js';
import { CHAMPS, JOINTURE_ETIQUETTES } from '../../application/csv.js';
/** La clé du nom de chaque encodage que rend decoder, dans l'ordre de ses
* quatre temps. */
@ -37,6 +37,7 @@ const CLES_DES_CHAMPS = new Map([
['titre_pressenti', 'import.champ.titrePressenti'],
['exclu', 'import.champ.exclu'],
['notes', 'import.champ.notes'],
['etiquettes', 'import.champ.etiquettes'],
]);
// La clé de la valeur lue d'« exclu » ; une cellule vide n'en a pas.
@ -186,13 +187,15 @@ function colonnesDe({ entetes, association }) {
}
// Chaque ligne valide : son rang, puis une cellule par champ, dans l'ordre
// de CHAMPS : le texte lu, ou la clé de la valeur d'« exclu ».
// de CHAMPS : le texte lu, ou la clé de la valeur d'« exclu » ; les
// étiquettes jointes comme l'export les joint, null sans étiquette.
function lignesDe(lignes) {
return lignes.map(({ ligne, champs }) => ({
ligne,
cellules: CHAMPS.map((champ) => {
const valeur = champs[CHAMPS_LUS.get(champ) ?? champ];
if (champ === 'exclu') return { champ, texte: null, cle: valeur === null ? null : CLES_D_EXCLU.get(valeur) };
if (champ === 'etiquettes') return { champ, texte: valeur === null ? null : valeur.join(JOINTURE_ETIQUETTES), cle: null };
return { champ, texte: valeur, cle: null };
}),
}));
@ -319,7 +322,7 @@ export function modeleApercu({ encodage = null, apercu = null, refusDecodage = n
/**
* Le texte relu dans l'ordre de la grille : précédé d'une ligne d'en-têtes —
* les largeur premiers de CHAMPS, puis des en-têtes vides au-delà de sept —,
* les largeur premiers de CHAMPS, puis des en-têtes vides au-delà de huit —,
* joints par le séparateur de l'aperçu. Sous AUCUN_ENTETE d'un texte non
* blanc, largeur vaut apercu.entetes.length ; sous SEPARATEUR_INTROUVABLE
* sans candidat (refusGlobalLigne null : une seule colonne), largeur vaut 1

View file

@ -79,20 +79,23 @@ describe('avecEntetesDeLaGrille : un collage sans en-tête lu dans l’ordre de
assert.deepEqual(nomsLus(lu), ['Ombrelle', 'Pervenche']);
});
test('neuf colonnes : les sept champs de la grille, puis deux en-têtes vides, listés non reconnus', () => {
const rangee = (nom) => [nom, 'Iris', 'Grisaille', 'iris@exemple.test', 'animation', 'non', 'note', 'x1', 'x2'].join(TAB);
test('dix colonnes : les huit champs de l’export, la huitième celle des étiquettes, puis deux en-têtes vides, listés non reconnus', () => {
const rangee = (nom) =>
[nom, 'Iris', 'Grisaille', 'iris@exemple.test', 'animation', 'non', 'note', 'finance|Montréal', 'x1', 'x2'].join(TAB);
const texte = lignes(rangee('Ombrelle'), rangee('Lacasse'));
const sans = apercevoir(texte);
assert.equal(sans.refusGlobal, 'AUCUN_ENTETE');
assert.equal(sans.entetes.length, 9);
assert.equal(sans.entetes.length, 10);
const avec = avecEntetesDeLaGrille(texte, sans);
assert.equal(avec.split('\n')[0], [...CHAMPS, '', ''].join(TAB));
const lu = apercevoir(avec);
assert.equal(lu.refusGlobal, null);
assert.equal(CHAMPS.length, 7);
assert.equal(CHAMPS.length, 8);
assert.equal(CHAMPS[7], 'etiquettes');
assert.deepEqual(lu.association.colonnes, Object.fromEntries(CHAMPS.map((champ, rang) => [champ, rang])));
assert.deepEqual(lu.association.nonReconnues, [7, 8]);
assert.deepEqual(lu.association.nonReconnues, [8, 9]);
assert.deepEqual(nomsLus(lu), ['Ombrelle', 'Lacasse']);
assert.deepEqual(lu.lignes.map(({ champs }) => champs.etiquettes), [['finance', 'Montréal'], ['finance', 'Montréal']]);
});
test('une liste de noms d’une colonne, « Lacasse, Ondine » compris : SEPARATEUR_INTROUVABLE sans rang, puis une colonne, la ligne entière en nom', () => {
@ -268,9 +271,9 @@ describe('modeleApercu : ce que l’aperçu montre (§ 10.1)', () => {
});
test('chaque champ de CHAMPS a sa clé, qui se traduit', () => {
const texte = lignes(CHAMPS.join(';'), ['Ombrelle', 'Iris', 'Grisaille', 'i@exemple.test', 'x', 'non', 'n'].join(';'));
const texte = lignes(CHAMPS.join(';'), ['Ombrelle', 'Iris', 'Grisaille', 'i@exemple.test', 'x', 'non', 'n', 'finance'].join(';'));
const { colonnes } = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(texte) });
assert.equal(colonnes.length, 7);
assert.equal(colonnes.length, 8);
for (const { cle } of colonnes) {
assert.match(cle, /^import\.champ\./);
assert.ok(t(cle).length > 0, cle);
@ -312,12 +315,12 @@ describe('modeleApercu : ce que l’aperçu montre (§ 10.1)', () => {
});
describe('les lignes, les refus de la mise à jour, les personnes désignées, le mode proposé (§ 10.1)', () => {
test('chaque ligne valide montre ses sept champs dans l’ordre de CHAMPS ; exclu par sa clé, vide sans valeur', () => {
test('chaque ligne valide montre ses huit champs dans l’ordre de CHAMPS ; exclu par sa clé, vide sans valeur ; les étiquettes jointes par « , »', () => {
const texte = lignes(
'notes;exclu;nom;prenom',
'arrive tard;Oui;Ombrelle;Iris',
';;Pervenche;',
';0;Lacasse;Ondine',
'notes;exclu;nom;prenom;mots-clés',
'arrive tard;Oui;Ombrelle;Iris;',
';;Pervenche;;finance|Montréal',
';0;Lacasse;Ondine;',
);
const { lignes: lues } = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(texte) });
assert.equal(lues.length, 3);
@ -326,7 +329,8 @@ describe('les lignes, les refus de la mise à jour, les personnes désignées, l
assert.deepEqual(COLONNES_DES_LIGNES.map(({ champ }) => champ), [...CHAMPS]);
for (const { cle } of COLONNES_DES_LIGNES) assert.ok(t(cle).length > 0, cle);
const [premiere, deuxieme, troisieme] = lues;
assert.deepEqual(premiere.cellules.map(({ texte: valeur }) => valeur), ['Ombrelle', 'Iris', null, null, null, null, 'arrive tard']);
assert.deepEqual(premiere.cellules.map(({ texte: valeur }) => valeur), ['Ombrelle', 'Iris', null, null, null, null, 'arrive tard', null]);
assert.deepEqual(deuxieme.cellules.at(-1), { champ: 'etiquettes', texte: 'finance, Montréal', cle: null });
assert.equal(premiere.cellules[5].cle, 'import.exclu.oui');
assert.equal(deuxieme.cellules[5].cle, null);
assert.equal(troisieme.cellules[5].cle, 'import.exclu.non');

View file

@ -77,6 +77,10 @@ export const TABLE = new Map([
['communs.remede.changerTours', 'Changer le nombre de tours'],
['communs.remede.modifierReglage', 'Modifier le réglage'],
['communs.remede.changerCibleRedondance', 'Changer la cible de la somme de redondance'],
['communs.remede.changerEtiquettes', 'Changer les étiquettes'],
['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.reserver', 'Réserver la place'],
['communs.remede.retirerReservation', 'Retirer la réservation'],
['communs.remede.deplacerReservation', 'Déplacer la réservation'],

View file

@ -62,6 +62,7 @@ export const TABLE = new Map([
['import.champ.titrePressenti', 'titre pressenti'],
['import.champ.exclu', 'exclu'],
['import.champ.notes', 'notes'],
['import.champ.etiquettes', 'étiquettes'],
['import.exclu.oui', 'oui'],
['import.exclu.non', 'non'],
['import.colonne.nonReconnue', 'non reconnue : ignorée'],

View file

@ -299,7 +299,7 @@ describe('reservationsDe : les réservations d’une personne, pour la fiche (§
});
describe("l'ordre de la grille est celui de l'export (§ 10.2)", () => {
test('la cellule de rang i de la ligne exportée égale p[COLONNES[i]], exclu écrit « non »', () => {
test('la cellule de rang i de la ligne exportée égale p[COLONNES[i]], exclu écrit « non » ; l’export compte une colonne de plus, les étiquettes, en dernier', () => {
const p = {
id: 1,
nom: 'Ombrelle',
@ -309,15 +309,18 @@ describe("l'ordre de la grille est celui de l'export (§ 10.2)", () => {
titrePressenti: 'hôtesse',
notes: 'arrive à neuf heures',
exclu: false,
etiquettes: ['Montréal', 'finance'],
};
const { texte } = decoder(exporterParticipants([p]));
const [, ligne] = texte.split('\r\n');
const [entete, ligne] = texte.split('\r\n');
const cellules = ligne.split(';');
assert.equal(COLONNES.length, 7);
assert.equal(cellules.length, COLONNES.length);
assert.equal(cellules.length, COLONNES.length + 1);
COLONNES.forEach((colonne, i) => {
assert.equal(cellules[i], colonne === 'exclu' ? 'non' : p[colonne], colonne);
});
assert.equal(entete.split(';').at(-1), 'etiquettes');
assert.equal(cellules.at(-1), 'Montréal, finance');
});
test('le nom proposé à l’enregistrement : ‹base›-participants.csv', () => {

View file

@ -32,6 +32,7 @@ import { decoder } from '../csv/encodage.js';
import { CATALOGUE } from '../demo/catalogue.js';
import { decouper } from '../csv/lecture.js';
import { analyser } from '../stockage/document.js';
import { lireEtiquettes } from '../stockage/etiquettes.js';
import { lireJournal } from '../stockage/journal.js';
import { SUFFIXES } from '../stockage/noms.js';
import { VERSION } from '../version.genere.js';
@ -100,20 +101,19 @@ const LETTRE = /\p{L}/u;
/**
* Ce qu'un écran peut montrer sans la marque de la langue témoin, parce que
* c'est une donnée (§ 14.6, « La traduction ») : noms, prénoms, noms
* affichés, appartenances, courriels, titres et notes des personnes, nom et
* base des événements du disque, libellés des titres, libellés figés du
* journal, version, provenance, identifiant et instant d'un verrou, cellules
* du CSV servi, nom de chaque démonstration du catalogue — le nom que
* l'événement chargé porte, que l'accueil reçoit comme une donnée — ; un
* chemin sous une racine du système simulé.
* → { valeurs: string[], racines: string[] }, valeurs de la plus longue à la
* plus courte.
* affichés, appartenances, courriels, titres, notes et étiquettes des
* personnes, nom et base des événements du disque, libellés des titres,
* libellés figés du journal, version, provenance, identifiant et instant d'un
* verrou, cellules du CSV servi, nom de chaque démonstration du catalogue —
* le nom que l'événement chargé porte, que l'accueil reçoit comme une donnée
* — ; un chemin sous une racine du système simulé. → { valeurs: string[],
* racines: string[] }, valeurs de la plus longue à la plus courte.
*/
async function donneesConnues(contexte, { cellules = [] } = {}) {
const valeurs = new Set([VERSION.affichee, VERSION.windows, PROVENANCE, ...CATALOGUE.map(({ nom }) => nom), ...cellules]);
const racines = new Set();
const personne = (p) => {
for (const v of [p.nom, p.prenom, p.appartenance, p.courriel, p.titrePressenti, p.notes]) if (v) valeurs.add(v);
for (const v of [p.nom, p.prenom, p.appartenance, p.courriel, p.titrePressenti, p.notes, ...(p.etiquettes ?? [])]) if (v) valeurs.add(v);
if (p.nom) valeurs.add(nomAffiche(p));
};
for (const m of contexte.montages) {
@ -158,10 +158,12 @@ async function donneesConnues(contexte, { cellules = [] } = {}) {
}
/** Le nom du CSV des cas limites et ses cellules, telles que la découpe
* les rend. */
* les rend, avec les étiquettes que chaque cellule porte : l'aperçu les
* montre une à une. */
async function cellulesCasLimites() {
const { texte } = decoder(await octetsCasLimites());
return [NOM_CAS_LIMITES, ...decouper(texte, ';').enregistrements.flat().map((c) => c.trim()).filter((c) => c !== '')];
const cellules = decouper(texte, ';').enregistrements.flat().map((c) => c.trim()).filter((c) => c !== '');
return [NOM_CAS_LIMITES, ...cellules, ...cellules.flatMap((c) => lireEtiquettes(c).etiquettes)];
}
// --- La langue témoin et les emoji ---------------------------------------------

View file

@ -2,8 +2,15 @@
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Passage de la configuration, forme par identifiants, à l'instance indexée
// que consomme le moteur (§ 4, § 5.2), et conversion d'un plan entre ses deux
// formes (§ 8.9). Les formes sont décrites dans types.js.
// que consomme le moteur (§ 4, § 4.5, § 5.2), et conversion d'un plan entre
// ses deux formes (§ 8.9). Les formes sont décrites dans types.js.
//
// L'instance porte aussi ce que les critères des objectifs et des animateurs
// lisent, calculé une fois par configuration : l'objectif en vigueur à chaque
// table et chaque tour, la correspondance de chaque personne à chaque
// objectif distinct, ses couples (personne, tour) possibles, les animateurs
// de chaque table à chaque tour et la borne des animateurs de chacun ; puis
// l'état de chaque critère et la liste des priorités complète.
//
// Ce module définit aussi, une seule fois pour tout le moteur (§ 13.2), ce
// que les autres modules lisent de ces formes : les valeurs du contrat —
@ -22,15 +29,30 @@
// ne produit : un identifiant de participant ou de table qui n'est pas un
// entier ≥ 1, une appartenance ni chaîne ni null, un exclu présent et non
// booléen, une portée ni « tous » ni « tour », des contraintes qui ne sont
// pas un objet de quatre booléens, une liste qui n'est pas un tableau. Ces
// formes sont les seules que normaliser contrôle par TypeError ; une valeur
// qui en sort est une faute de l'appelant, qu'aucune valeur par défaut ne
// masque ;
// pas un objet de quatre booléens, ou dont une case facultative présente
// n'est pas booléenne, une liste qui n'est pas un tableau, des étiquettes
// qui ne sont pas une liste de chaînes, une règle d'objectif ni « ou » ni
// « et », un mode des objectifs inconnu, des tours animés qui ne croissent
// pas strictement. Ces formes sont les seules que normaliser contrôle par
// TypeError ; une valeur qui en sort est une faute de l'appelant, qu'aucune
// valeur par défaut ne masque ;
// - RangeError, pour un plan indexé qui n'a pas la forme de l'instance : seul
// le moteur produit cette forme, et l'écart y est une faute de code.
//
// Les champs que normaliser ne lit pas — nom, prénom, numéro de table — ne
// sont pas examinés. Aucune fonction ne modifie ce qu'elle reçoit.
// sont pas examinés, pas plus que les clés des contraintes autres que les six
// qu'il copie : le moteur ne parcourt les clés d'aucun objet. Aucune fonction
// ne modifie ce qu'elle reçoit.
import { correspondA } from './correspondance.js';
import {
CRITERES,
MODES_OBJECTIFS,
PRIORITES_PAR_DEFAUT,
completerPriorites,
estCoche,
valeursQuiClassent,
verifierPriorites,
} from './criteres.js';
import { ErreurConfiguration } from './erreurs.js';
/**
@ -54,6 +76,12 @@ export const RESERVE = -1;
/** instance.groupe d'un participant sans appartenance. */
export const SANS_GROUPE = -1;
/** Case de instance.objectifDe d'une table sans objectif en vigueur au tour. */
export const SANS_OBJECTIF = -1;
/** instance.rangAnimateur d'une personne qui n'anime à aucun tour. */
export const NON_ANIMATEUR = -1;
// Case d'un plan indexé qu'indexerPlan n'a pas encore remplie ; jamais rendue.
const NON_PLACE = -2;
@ -68,8 +96,13 @@ const NOMS_CONTRAINTES = [
'varierAppartenances',
];
// Valeur citée dans un message : une chaîne s'écrit entre guillemets, pour
// ne pas se lire comme le nombre qu'elle contient.
// Les deux cases facultatives (A16), absentes à leur défaut : la copie ne les
// porte que quand la configuration les porte.
const CONTRAINTES_FACULTATIVES = ['respecterObjectifs', 'rencontrerAnimateurs'];
// Le mode des objectifs d'une configuration qui n'en porte pas (D20).
const MODE_PAR_DEFAUT = MODES_OBJECTIFS[0];
// Valeur citée dans un message : une chaîne entre guillemets, pour qu'elle
// ne se lise pas comme le nombre qu'elle contient ; toute autre valeur telle
// quelle.
@ -102,8 +135,17 @@ function estExclu({ id, exclu }) {
return exclu;
}
// Copie des quatre contraintes : une modification ultérieure de la
// configuration n'atteint pas l'instance.
// Lève TypeError quand valeurs n'est pas une liste de chaînes ; quoi nomme
// la liste dans le message.
function exigerEtiquettes(valeurs, quoi) {
if (!Array.isArray(valeurs) || !valeurs.every((valeur) => typeof valeur === 'string')) {
throw new TypeError(`${quoi} : liste de chaînes attendue`);
}
}
// Copie des quatre contraintes, puis des deux cases facultatives que la
// configuration porte : une modification ultérieure de la configuration
// n'atteint pas l'instance. Aucune autre clé n'est lue.
function copierContraintes(contraintes) {
if (typeof contraintes !== 'object' || contraintes === null) {
throw new TypeError('configuration.contraintes : objet attendu');
@ -115,9 +157,26 @@ function copierContraintes(contraintes) {
}
copie[nom] = contraintes[nom];
}
for (const nom of CONTRAINTES_FACULTATIVES) {
if (contraintes[nom] === undefined) continue;
if (typeof contraintes[nom] !== 'boolean') {
throw new TypeError(`contrainte ${nom} : booléen attendu, reçu ${decrire(contraintes[nom])}`);
}
copie[nom] = contraintes[nom];
}
return copie;
}
// Le mode des objectifs : MODE_PAR_DEFAUT quand la configuration n'en porte
// pas ; TypeError pour un mode hors de MODES_OBJECTIFS.
function lireMode(mode) {
if (mode === undefined) return MODE_PAR_DEFAUT;
if (!MODES_OBJECTIFS.includes(mode)) {
throw new TypeError(`configuration.modeObjectifs : ${decrire(mode)}, l'un de ${MODES_OBJECTIFS.join(', ')} attendu`);
}
return mode;
}
// Groupe de chaque participant présent, présents triés par identifiant
// croissant : un libellé reçoit le numéro de sa première apparition,
// SANS_GROUPE marque l'absence d'appartenance. L'égalité de chaînes fait le
@ -195,11 +254,13 @@ function fixerReservations(reservations, contexte) {
return fixe;
}
// Lève SURRESERVATION quand, à un tour, une table reçoit plus de personnes
// fixées que de sièges (§ 5.9). Le compte porte sur les personnes, lues dans
// fixe : une réservation répétée ne compte qu'une fois. Les tables se
// parcourent par index, chacune par tours croissants.
function verifierSurreservation(fixe, { N, T, R, capacite, idsTables }) {
// Les sièges libres de chaque table à chaque tour, [t × R + r] : sa capacité
// moins les personnes que fixe y assied. Lève SURRESERVATION quand, à un
// tour, une table reçoit plus de personnes fixées que de sièges (§ 5.9). Le
// compte porte sur les personnes, lues dans fixe : une réservation répétée ne
// compte qu'une fois. Les tables se parcourent par index, chacune par tours
// croissants.
function siegesLibresApres(fixe, { N, T, R, capacite, idsTables }) {
const fixees = new Int32Array(T * R);
for (let p = 0; p < N; p += 1) {
for (let r = 0; r < R; r += 1) {
@ -217,8 +278,10 @@ function verifierSurreservation(fixe, { N, T, R, capacite, idsTables }) {
capacite: capacite[t],
});
}
fixees[t * R + r] = capacite[t] - fixees[t * R + r];
}
}
return fixees;
}
// Statut de chaque participant, dérivé de fixe et non de la forme des
@ -252,6 +315,236 @@ function deriverStatuts(fixe, N, T, R) {
return { statut, ancresParTable, k };
}
// Les objectifs de la configuration, rangés et numérotés (§ 4.5, A13).
// Chacun se vérifie dans sa forme et dans son ordre : table connue, tour nul
// pour la soirée ou de 1 à R, au plus un objectif par table pour la soirée et
// par tour. Les objectifs distincts — même règle, mêmes étiquettes dans le
// même ordre — reçoivent un seul numéro, de 0 à O − 1, dans l'ordre des
// tables par identifiant croissant, la soirée puis les tours croissants de
// chacune ; la Map ne sert qu'à retrouver un objectif déjà numéroté.
// objectifDe[t × R + r] donne le numéro de l'objectif en vigueur, celui du
// tour quand la table en porte un, sinon celui de la soirée, SANS_OBJECTIF
// sinon : l'objectif du tour remplace celui de la soirée.
function numeroterObjectifs(objectifs, { T, R, idsTables, indexTableDe }) {
exigerListe(objectifs, 'objectifs');
// rangDe[t × (R + 1) + k] : rang dans objectifs de l'objectif de la table t
// pour la soirée (k = 0) ou pour le tour k ; −1 sans objectif.
const rangDe = new Int32Array(T * (R + 1)).fill(-1);
objectifs.forEach(({ table, tour, regle, etiquettes }, rang) => {
const t = indexTableDe.get(table);
if (t === undefined) throw new ErreurConfiguration('OBJECTIF_INCONNU', { table });
if (regle !== 'ou' && regle !== 'et') {
throw new TypeError(`objectif de la table ${table} : règle ${decrire(regle)}, « ou » ou « et » attendue`);
}
exigerEtiquettes(etiquettes, `objectif de la table ${table}`);
const soiree = tour === null || tour === undefined;
if (!soiree && !(Number.isInteger(tour) && tour >= 1 && tour <= R)) {
throw new ErreurConfiguration('OBJECTIF_TOUR', { table, tour });
}
const k = t * (R + 1) + (soiree ? 0 : tour);
if (rangDe[k] !== -1) throw new ErreurConfiguration('OBJECTIF_CONFLIT', { table, tour: soiree ? null : tour });
rangDe[k] = rang;
});
const parIdentifiant = Array.from({ length: T }, (_, t) => t).sort((a, b) => idsTables[a] - idsTables[b]);
const numeroDe = new Int32Array(T * (R + 1)).fill(SANS_OBJECTIF);
const distincts = [];
const numeroDeCle = new Map();
for (const t of parIdentifiant) {
for (let k = 0; k <= R; k += 1) {
const rang = rangDe[t * (R + 1) + k];
if (rang === -1) continue;
const { regle, etiquettes } = objectifs[rang];
const cle = JSON.stringify([regle, etiquettes]);
if (!numeroDeCle.has(cle)) {
numeroDeCle.set(cle, distincts.length);
distincts.push({ regle, etiquettes });
}
numeroDe[t * (R + 1) + k] = numeroDeCle.get(cle);
}
}
const objectifDe = new Int32Array(T * R);
for (let t = 0; t < T; t += 1) {
for (let r = 0; r < R; r += 1) {
const duTour = numeroDe[t * (R + 1) + r + 1];
objectifDe[t * R + r] = duTour !== SANS_OBJECTIF ? duTour : numeroDe[t * (R + 1)];
}
}
return { objectifDe, distincts };
}
// Correspondance de chaque personne présente à chaque objectif distinct,
// [p × O + o] = 1 quand correspondA le dit (D18), puis les couples possibles
// (§ 4.5) : (p, r) l'est quand la table fixée de p à ce tour lui correspond,
// ou, p libre à ce tour, quand une table qui garde un siège libre lui
// correspond. Les objectifs qu'offre une table à siège libre se marquent une
// fois par tour.
function couplesPossibles(presents, distincts, { N, T, R, fixe, siegesLibres, objectifDe }) {
const O = distincts.length;
const correspond = new Uint8Array(N * O);
for (let p = 0; p < N; p += 1) {
const etiquettes = presents[p].etiquettes ?? [];
for (let o = 0; o < O; o += 1) correspond[p * O + o] = correspondA(etiquettes, distincts[o]) ? 1 : 0;
}
const possible = new Uint8Array(N * R);
const possibles = new Int32Array(N);
const offert = new Uint8Array(O);
for (let r = 0; r < R; r += 1) {
offert.fill(0);
for (let t = 0; t < T; t += 1) {
const o = objectifDe[t * R + r];
if (o !== SANS_OBJECTIF && siegesLibres[t * R + r] > 0) offert[o] = 1;
}
for (let p = 0; p < N; p += 1) {
const t = fixe[p * R + r];
let oui = false;
if (t !== LIBRE) {
const o = objectifDe[t * R + r];
oui = o !== SANS_OBJECTIF && correspond[p * O + o] === 1;
} else {
for (let o = 0; o < O && !oui; o += 1) oui = offert[o] === 1 && correspond[p * O + o] === 1;
}
if (oui) {
possible[p * R + r] = 1;
possibles[p] += 1;
}
}
}
return { O, correspond, possible, possibles };
}
// Les animateurs (§ 4.5, D6) : participant.anime liste, croissants, les tours
// où la personne présente pourvoit un titre. Chaque tour se vérifie : de 1 à
// R, et la personne y est fixée — sinon ANIMATION_NON_FIXEE ; elle anime
// alors à sa table fixée. Un exclu n'anime pas. Les animateurs reçoivent
// leurs rangs, 0 à A − 1, dans l'ordre des identifiants ; animes porte, ligne
// t × R + r de debutAnimes à la suivante, les rangs de ceux qui animent à la
// table t au tour r, croissants.
function deriverAnimateurs(presents, { N, T, R, fixe }) {
const rangAnimateur = new Int32Array(N).fill(NON_ANIMATEUR);
const indexDesAnimateurs = [];
const parLigne = new Int32Array(T * R);
for (let p = 0; p < N; p += 1) {
const { id, anime } = presents[p];
if (anime === undefined) continue;
if (!Array.isArray(anime)) throw new TypeError(`participant ${id} : tours animés en liste attendus`);
let precedent = 0;
for (const tour of anime) {
if (!Number.isInteger(tour) || tour < 1 || tour > R || fixe[p * R + tour - 1] === LIBRE) {
throw new ErreurConfiguration('ANIMATION_NON_FIXEE', { participant: id, tour });
}
if (tour <= precedent) throw new TypeError(`participant ${id} : tours animés strictement croissants attendus`);
precedent = tour;
parLigne[fixe[p * R + tour - 1] * R + tour - 1] += 1;
}
if (anime.length === 0) continue;
rangAnimateur[p] = indexDesAnimateurs.length;
indexDesAnimateurs.push(p);
}
const debutAnimes = new Int32Array(T * R + 1);
for (let ligne = 0; ligne < T * R; ligne += 1) debutAnimes[ligne + 1] = debutAnimes[ligne] + parLigne[ligne];
const animes = new Int32Array(debutAnimes[T * R]);
const rempli = debutAnimes.slice(0, T * R);
for (const p of indexDesAnimateurs) {
for (const tour of presents[p].anime) {
const ligne = fixe[p * R + tour - 1] * R + tour - 1;
animes[rempli[ligne]] = rangAnimateur[p];
rempli[ligne] += 1;
}
}
return { A: indexDesAnimateurs.length, rangAnimateur, animateurs: Int32Array.from(indexDesAnimateurs), debutAnimes, animes };
}
// La borne des animateurs B(p) = min(#Φ(p), Σ_r φ_r(p)) (D21). Une table que
// p peut occuper au tour r est sa table fixée, sinon toute table qui garde un
// siège libre. Φ(p) réunit les animateurs autres que p qui animent, à un
// tour, à une table qu'il peut y occuper : un marquage de taille A, daté par
// personne, les compte sans remise à zéro. φ_r(p) est le plus grand nombre
// d'animateurs autres que p à une telle table : pour p fixé, ceux de sa
// table ; pour p libre, qui n'anime pas à ce tour, le plus grand nombre d'une
// table à siège libre, calculé une fois par tour, comme la liste de leurs
// animateurs. O(N · R · A) au pire.
function bornerAnimateurs({ N, T, R, fixe, siegesLibres }, { A, rangAnimateur, debutAnimes, animes }) {
const borne = new Int32Array(N);
if (A === 0) return borne;
const plusGrandLibre = new Int32Array(R);
const libresDuTour = [];
for (let r = 0; r < R; r += 1) {
const rangs = [];
for (let t = 0; t < T; t += 1) {
if (siegesLibres[t * R + r] === 0) continue;
const debut = debutAnimes[t * R + r];
const fin = debutAnimes[t * R + r + 1];
for (let k = debut; k < fin; k += 1) rangs.push(animes[k]);
plusGrandLibre[r] = Math.max(plusGrandLibre[r], fin - debut);
}
libresDuTour.push(rangs);
}
const marque = new Int32Array(A).fill(-1);
for (let p = 0; p < N; p += 1) {
const soi = rangAnimateur[p];
let rejoints = 0;
let somme = 0;
const rejoindre = (f) => {
if (f === soi || marque[f] === p) return;
marque[f] = p;
rejoints += 1;
};
for (let r = 0; r < R; r += 1) {
const t = fixe[p * R + r];
if (t === LIBRE) {
libresDuTour[r].forEach(rejoindre);
somme += plusGrandLibre[r];
continue;
}
let ici = 0;
for (let k = debutAnimes[t * R + r]; k < debutAnimes[t * R + r + 1]; k += 1) {
if (animes[k] === soi) continue;
rejoindre(animes[k]);
ici += 1;
}
somme += ici;
}
borne[p] = Math.min(rejoints, somme);
}
return borne;
}
// La liste des priorités de la configuration, complète (A16) : une copie de
// l'ordre par défaut quand elle n'en porte pas ; sinon la liste reçue,
// complétée, que verifierPriorites juge sous le mode. Le jugement se fait
// avant le complément, qui suppose la forme d'une liste : verifierPriorites
// ne relève l'absence d'un critère qu'une fois tout le reste trouvé valide, et
// le complément n'ajoute que des paliers d'un critère seul au poids 1, si
// bien qu'une liste dont la seule faute est l'absence se complète en une
// liste valide, et que toute autre faute est celle de la liste complétée.
// Les détails de PRIORITES sont la faute de verifierPriorites.
function lirePriorites(priorites, mode) {
if (priorites === undefined) return completerPriorites(PRIORITES_PAR_DEFAUT);
const faute = verifierPriorites(priorites, mode);
if (faute !== null && faute.faute !== 'critereAbsent') throw new ErreurConfiguration('PRIORITES', faute);
return completerPriorites(priorites);
}
// L'état de chaque critère, dans l'ordre de CRITERES (§ 5.3, D10) : coché
// selon ses cases, sans objet quand l'événement ne lui donne rien à mesurer —
// aucun couple possible pour les tours hors objectif, aucune borne des
// animateurs non nulle pour les animateurs non rencontrés —, et les valeurs
// qu'il classe sous ces contraintes et ce mode.
function etatDesCriteres(contraintes, mode, possibles, borne) {
const sansObjet = {
toursHorsObjectif: possibles.every((n) => n === 0),
animateursNonRencontres: borne.every((b) => b === 0),
};
return CRITERES.map((nom) => ({
nom,
coche: estCoche(nom, contraintes),
sansObjet: sansObjet[nom] ?? false,
valeurs: valeursQuiClassent(nom, contraintes, mode),
}));
}
/**
* Instance indexée d'une configuration (§ 4, § 5.2). Les participants exclus
* en sont absents (§ 4.4) : ni index ni groupe, et leurs réservations,
@ -264,19 +557,32 @@ function deriverStatuts(fixe, N, T, R) {
* (participant ou table), RESERVATION_TOUR (tour absent ou hors de 1..R),
* RESERVATION_CONFLIT (une personne à deux tables d'un même tour),
* SURRESERVATION (plus de personnes fixées que de sièges à une table, à un
* tour). Une salle trop petite n'est pas refusée : nombrePlacesManquantes la
* chiffre, et la recherche la refuse (§ 5.9). Les détails désignent
* participants et tables par identifiant, les tours par leur numéro à partir
* de 1, et la réservation fautive par son rang dans
* tour) ; puis objectifs un à un, tours animés, priorités : OBJECTIF_INCONNU
* {table} (une table absente), OBJECTIF_TOUR {table, tour} (ni nul ni de
* 1..R), OBJECTIF_CONFLIT {table, tour} (deux objectifs d'une table pour la
* soirée, tour nul, ou pour un même tour), ANIMATION_NON_FIXEE {participant,
* tour} (un tour animé hors de 1..R, ou où la personne n'est pas fixée),
* PRIORITES {faute, critere?, palier?} (la faute de verifierPriorites sous le
* mode, après complément). Une salle trop petite n'est pas refusée :
* nombrePlacesManquantes la chiffre, et la recherche la refuse (§ 5.9). Les
* détails désignent participants et tables par identifiant, les tours par
* leur numéro à partir de 1, et la réservation fautive par son rang dans
* configuration.reservations, à partir de 0.
*
* Lève TypeError sur les seules formes qu'énumère l'en-tête du module :
* identifiants de participant et de table, appartenance, exclu, portée,
* contraintes, listes. Une capacité, un nombre de tours ou un tour désigné
* hors de leur forme lèvent CAPACITE, TOURS ou RESERVATION_TOUR ; une
* réservation qui désigne un participant ou une table absents de la
* configuration lève RESERVATION_INCONNUE, quelle que soit la forme de la
* valeur qui les désigne.
* contraintes, listes, étiquettes, règle d'un objectif, mode, ordre des
* tours animés. Une capacité, un nombre de tours, un tour désigné, le tour
* d'un objectif ou un tour animé hors de leur forme lèvent CAPACITE, TOURS,
* RESERVATION_TOUR, OBJECTIF_TOUR ou ANIMATION_NON_FIXEE ; une réservation ou
* un objectif qui désignent un participant ou une table absents de la
* configuration lèvent RESERVATION_INCONNUE ou OBJECTIF_INCONNU, quelle que
* soit la forme de la valeur qui les désigne.
*
* Les champs facultatifs absents prennent leur défaut : aucun objectif, aucune
* étiquette, personne n'anime, le mode « places », l'ordre par défaut ; les
* deux cases facultatives restent absentes de instance.contraintes, où
* estCoche (criteres.js) leur donne leur valeur par défaut.
*
* @param {import('./types.js').Configuration} configuration
* @returns {import('./types.js').Instance}
@ -287,6 +593,7 @@ export function normaliser(configuration) {
exigerListe(tables, 'tables');
exigerListe(reservations, 'reservations');
const contraintes = copierContraintes(configuration.contraintes);
const modeObjectifs = lireMode(configuration.modeObjectifs);
// connus porte tous les identifiants, exclus compris : un doublon reste un
// doublon, et la réservation d'un exclu n'est pas inconnue.
@ -300,6 +607,7 @@ export function normaliser(configuration) {
}
connus.add(participant.id);
exigerAppartenance(participant);
if (participant.etiquettes !== undefined) exigerEtiquettes(participant.etiquettes, `participant ${participant.id}, étiquettes`);
if (estExclu(participant)) exclus.push(participant.id);
else presents.push(participant);
}
@ -329,8 +637,13 @@ export function normaliser(configuration) {
const indexDe = new Map(ids.map((id, p) => [id, p]));
const { groupe, groupes } = numeroterGroupes(presents);
const fixe = fixerReservations(reservations, { connus, indexDe, indexTableDe, idsTables, N, R });
verifierSurreservation(fixe, { N, T, R, capacite, idsTables });
const siegesLibres = siegesLibresApres(fixe, { N, T, R, capacite, idsTables });
const { statut, ancresParTable, k } = deriverStatuts(fixe, N, T, R);
const { objectifDe, distincts } = numeroterObjectifs(configuration.objectifs ?? [], { T, R, idsTables, indexTableDe });
const objectifs = couplesPossibles(presents, distincts, { N, T, R, fixe, siegesLibres, objectifDe });
const animation = deriverAnimateurs(presents, { N, T, R, fixe });
const borneAnimateurs = bornerAnimateurs({ N, T, R, fixe, siegesLibres }, animation);
const priorites = lirePriorites(configuration.priorites, modeObjectifs);
return {
N,
@ -350,6 +663,21 @@ export function normaliser(configuration) {
k,
n: N - k,
contraintes,
siegesLibres,
O: objectifs.O,
objectifDe,
correspond: objectifs.correspond,
possible: objectifs.possible,
possibles: objectifs.possibles,
modeObjectifs,
A: animation.A,
rangAnimateur: animation.rangAnimateur,
animateurs: animation.animateurs,
debutAnimes: animation.debutAnimes,
animes: animation.animes,
borneAnimateurs,
etatCriteres: etatDesCriteres(contraintes, modeObjectifs, objectifs.possibles, borneAnimateurs),
priorites,
};
}

View file

@ -14,8 +14,10 @@ import { describe, test } from '../../test/lanceur.js';
import { ErreurAnnulee, ErreurConfiguration } from './erreurs.js';
import {
LIBRE,
NON_ANIMATEUR,
RESERVE,
SANS_GROUPE,
SANS_OBJECTIF,
STATUT,
exigerPlanIndexe,
indexerPlan,
@ -25,6 +27,7 @@ import {
planDepuisIndex,
replierParPopulation,
} from './configuration.js';
import { CRITERES, PRIORITES_PAR_DEFAUT, verifierPriorites } from './criteres.js';
import { mesurer } from './indicateurs.js';
import { plafondsRealises } from './plafond.js';
@ -893,3 +896,325 @@ describe('erreurs du moteur', () => {
assert.equal(erreur.name, 'ErreurAnnulee');
});
});
// --- Les étiquettes, les objectifs, les animateurs (§ 4.5, A7, A21) ---------
// Participant présent qui porte des étiquettes, et éventuellement les tours
// où il anime.
const etiquete = (id, etiquettes, anime) => ({
id,
nom: `P${id}`,
appartenance: null,
...(etiquettes === undefined ? {} : { etiquettes }),
...(anime === undefined ? {} : { anime }),
});
const objectif = (table, tour, regle, etiquettes) => ({ table, tour, regle, etiquettes });
// La configuration de la correspondance : trois tables, dont 22 gelée au
// tour 2 par deux réservations ; des objectifs de soirée et de tour, des deux
// règles, déclarés hors de l'ordre des tables, dont deux identiques sur deux
// tables ; une personne réservée à une table sans objectif quand une autre la
// servait, une autre réservée à une table dont l'objectif ne lui correspond
// pas ; un exclu qui porte des étiquettes.
const CORRESPONDANCE = configuration({
participants: [
etiquete(2, ['finance']),
etiquete(3, ['finance', 'Montréal']),
etiquete(4, ['ressources humaines']),
etiquete(5),
etiquete(6, ['Montréal']),
etiquete(7, ['bénévole', 'finance']),
etiquete(8, ['bénévole']),
{ ...etiquete(9, ['finance']), exclu: true },
etiquete(10, ['bénévole']),
],
tables: [{ id: 21, numero: 1, capacite: 4 }, { id: 23, numero: 2, capacite: 2 }, { id: 22, numero: 3, capacite: 2 }],
tours: 3,
reservations: [auTour(4, 23, 1), auTour(6, 21, 3), tous(7, 22), auTour(8, 22, 2), tous(9, 21)],
});
CORRESPONDANCE.objectifs = [
objectif(23, 3, 'ou', ['Montréal']),
objectif(21, null, 'ou', ['finance', 'ressources humaines']),
objectif(22, 1, 'ou', ['finance', 'ressources humaines']),
objectif(21, 2, 'et', ['finance', 'Montréal']),
objectif(22, null, 'ou', ['bénévole']),
];
// Lecture directe du § 4.5, sans rien du module éprouvé : l'objectif en
// vigueur à la table au tour r est celui du tour s'il existe, sinon celui de
// la soirée ; « ou » demande une étiquette commune, « et » chacune ; une
// table garde un siège libre quand ses réservations présentes n'occupent pas
// toute sa capacité ; un couple fixé n'est possible qu'à sa table.
function lectureDirecte(config) {
const presents = config.participants.filter((personne) => personne.exclu !== true);
const enVigueur = (table, tour) =>
config.objectifs.find((o) => o.table === table && o.tour === tour)
?? config.objectifs.find((o) => o.table === table && o.tour === null);
const correspond = (personne, table, tour) => {
const o = enVigueur(table, tour);
if (o === undefined) return false;
const siennes = personne.etiquettes ?? [];
return o.regle === 'ou' ? o.etiquettes.some((e) => siennes.includes(e)) : o.etiquettes.every((e) => siennes.includes(e));
};
const couvre = (reservation, tour) => reservation.portee === 'tous' || reservation.tour === tour;
const presentes = config.reservations.filter((reservation) => presents.some(({ id }) => id === reservation.participant));
const tableFixee = (personne, tour) =>
presentes.find((reservation) => reservation.participant === personne.id && couvre(reservation, tour))?.table;
const libre = (table, tour) =>
presentes.filter((reservation) => reservation.table === table.id && couvre(reservation, tour)).length < table.capacite;
const possible = (personne, tour) => {
const fixee = tableFixee(personne, tour);
if (fixee !== undefined) return correspond(personne, fixee, tour);
return config.tables.some((table) => libre(table, tour) && correspond(personne, table.id, tour));
};
return { presents, correspond, possible };
}
describe('normaliser : objectifs des tables et correspondance (§ 4.5, A7, A21)', () => {
test('une correspondance se décide par la règle : chaque couple (personne, table, tour) et chaque couple possible égalent la lecture directe du § 4.5', () => {
const instance = normaliser(CORRESPONDANCE);
const { presents, correspond, possible } = lectureDirecte(CORRESPONDANCE);
const { N, T, R, O, objectifDe } = instance;
assert.deepEqual(instance.ids, presents.map(({ id }) => id));
const ecarts = [];
for (let p = 0; p < N; p += 1) {
for (let r = 0; r < R; r += 1) {
for (let t = 0; t < T; t += 1) {
const o = objectifDe[t * R + r];
const lu = o !== SANS_OBJECTIF && instance.correspond[p * O + o] === 1;
if (lu !== correspond(presents[p], instance.idsTables[t], r + 1)) {
ecarts.push(`personne ${instance.ids[p]}, table ${instance.idsTables[t]}, tour ${r + 1} : ${lu}`);
}
}
const lu = instance.possible[p * R + r] === 1;
if (lu !== possible(presents[p], r + 1)) ecarts.push(`possible ${instance.ids[p]}, tour ${r + 1} : ${lu}`);
}
}
assert.deepEqual(ecarts, []);
});
test('le compte de chacun, compté à la main : l’objectif du tour remplace celui de la soirée, la table sans siège libre ne sert personne, la réservation tient hors objectif', () => {
const instance = normaliser(CORRESPONDANCE);
assert.deepEqual(instance.ids, [2, 3, 4, 5, 6, 7, 8, 10]);
// 2 : « finance » sert aux tours 1 et 3 à la table 21, mais au tour 2
// l'objectif « toutes : finance, Montréal » remplace celui de la soirée.
// 4 : réservé au tour 1 à 23, sans objectif, quand 21 le servait.
// 6 : réservé au tour 3 à 21, dont l'objectif ne lui correspond pas,
// quand 23 le servait. 10 : au tour 2, seule 22, gelée, lui correspond.
assert.deepEqual(instance.possibles, Int32Array.from([2, 3, 1, 0, 0, 3, 2, 1]));
assert.deepEqual(instance.siegesLibres, Int32Array.from([4, 4, 3, 1, 2, 2, 1, 0, 1]));
});
test('les objectifs distincts se numérotent par table d’identifiant croissant, soirée puis tours, deux objectifs identiques en un seul', () => {
const instance = normaliser(CORRESPONDANCE);
// Index de table 0, 1, 2 : identifiants 21, 23, 22. 21 soirée → 0,
// 21 tour 2 → 1, 22 soirée → 2, 22 tour 1 → 0 (le même que 21 soirée),
// 23 tour 3 → 3.
assert.equal(instance.O, 4);
assert.deepEqual(instance.objectifDe, Int32Array.from([0, 1, 0, SANS_OBJECTIF, SANS_OBJECTIF, 3, 0, 2, 2]));
assert.equal(instance.correspond.length, instance.N * 4);
});
test('sans objectif ni étiquette ni animateur, chaque champ nouveau prend sa valeur vide, et les cases facultatives restent absentes', () => {
const instance = normaliser(configuration({ participants: personnes([3, 1]), tables: tablesDe([12, 11], 2), tours: 2 }));
assert.equal(instance.O, 0);
assert.deepEqual(instance.objectifDe, new Int32Array(4).fill(SANS_OBJECTIF));
assert.deepEqual(instance.correspond, new Uint8Array(0));
assert.deepEqual(instance.possible, new Uint8Array(4));
assert.deepEqual(instance.possibles, new Int32Array(2));
assert.equal(instance.modeObjectifs, 'places');
assert.equal(instance.A, 0);
assert.deepEqual(instance.rangAnimateur, new Int32Array(2).fill(NON_ANIMATEUR));
assert.deepEqual(instance.animateurs, new Int32Array(0));
assert.deepEqual(instance.debutAnimes, new Int32Array(5));
assert.deepEqual(instance.animes, new Int32Array(0));
assert.deepEqual(instance.borneAnimateurs, new Int32Array(2));
assert.deepEqual(instance.contraintes, SANS_CONTRAINTE);
assert.deepEqual(instance.priorites, PRIORITES_PAR_DEFAUT);
assert.notEqual(instance.priorites, PRIORITES_PAR_DEFAUT);
});
test('les deux cases facultatives se copient quand la configuration les porte, et aucune autre clé', () => {
const contraintes = { ...SANS_CONTRAINTE, respecterObjectifs: false, rencontrerAnimateurs: true, autre: true };
const instance = normaliser(configuration({ participants: personnes([1]), tables: tablesDe([11], 2), tours: 1, contraintes }));
contraintes.rencontrerAnimateurs = false;
assert.deepEqual(instance.contraintes, { ...SANS_CONTRAINTE, respecterObjectifs: false, rencontrerAnimateurs: true });
});
});
describe('normaliser : animateurs et borne des animateurs (§ 4.5, D6, D21)', () => {
// Tables 41 et 42 de trois places, deux tours. 5 anime aux deux tours à
// 42 ; 3 anime au seul tour 2 à 41 ; 7 au seul tour 1 à 42 ; 9, exclu,
// n'anime pas ; 1 et 2 sont libres.
const ANIMATION = configuration({
participants: [
etiquete(5, undefined, [1, 2]),
etiquete(1),
etiquete(7, undefined, [1]),
etiquete(3, undefined, [2]),
etiquete(2),
{ ...etiquete(9, undefined, [1]), exclu: true },
],
tables: tablesDe([41, 42], 3),
tours: 2,
reservations: [tous(5, 42), auTour(3, 41, 2), auTour(7, 42, 1), tous(9, 41)],
});
test('rangs des animateurs par identifiant croissant, et rangs de ceux qui animent à chaque table, à chaque tour', () => {
const instance = normaliser(ANIMATION);
assert.deepEqual(instance.ids, [1, 2, 3, 5, 7]);
assert.equal(instance.A, 3);
assert.deepEqual(instance.rangAnimateur, Int32Array.from([NON_ANIMATEUR, NON_ANIMATEUR, 0, 1, 2]));
assert.deepEqual(instance.animateurs, Int32Array.from([2, 3, 4]));
// Lignes t × R + r : (41, 1) aucun, (41, 2) 3, (42, 1) 5 et 7, (42, 2) 5.
assert.deepEqual(instance.debutAnimes, Int32Array.from([0, 0, 1, 3, 4]));
assert.deepEqual(instance.animes, Int32Array.from([0, 1, 2, 1]));
});
test('B(p) = min(#Φ(p), Σ φ_r(p)), compté à la main', () => {
const instance = normaliser(ANIMATION);
// 1 et 2, libres : Φ = {5, 7} au tour 1, {3, 5} au tour 2, soit trois ;
// φ = 2 au tour 1 (5 et 7 à 42), 1 au tour 2 : B = 3.
// 3 : libre au tour 1, Φ = {5, 7}, φ = 2 ; fixé à 41 au tour 2, où il
// anime seul : B = 2. 5 : fixé à 42, n'y rencontre que 7 au tour 1 : B = 1.
// 7 : fixé à 42 au tour 1, avec 5 ; libre au tour 2, Φ = {3, 5}, φ = 1 :
// B = min(2, 2) = 2.
assert.deepEqual(instance.borneAnimateurs, Int32Array.from([3, 3, 2, 1, 2]));
});
test('ANIMATION_NON_FIXEE : un tour hors de 1 à R, ou où la personne n’est pas fixée', () => {
const base = configuration({ participants: personnes([1, 2]), tables: tablesDe([11], 2), tours: 2, reservations: [tous(2, 11)] });
const avecAnime = (id, anime) => () => normaliser({
...base,
participants: base.participants.map((personne) => (personne.id === id ? { ...personne, anime } : personne)),
});
assert.deepEqual(ecartsDeRefus([
['un tour 3 sur deux', avecAnime(2, [1, 3]), 'ANIMATION_NON_FIXEE', { participant: 2, tour: 3 }],
['un tour 0', avecAnime(2, [0]), 'ANIMATION_NON_FIXEE', { participant: 2, tour: 0 }],
['un tour non entier', avecAnime(2, [1.5]), 'ANIMATION_NON_FIXEE', { participant: 2, tour: 1.5 }],
['un tour en texte', avecAnime(2, ['1']), 'ANIMATION_NON_FIXEE', { participant: 2, tour: '1' }],
['un tour où la personne est libre', avecAnime(1, [1]), 'ANIMATION_NON_FIXEE', { participant: 1, tour: 1 }],
]), []);
});
});
describe('normaliser : refus des objectifs, du mode et des priorités (A12, A16)', () => {
const base = configuration({ participants: personnes([1, 2, 3]), tables: tablesDe([11, 12], 2), tours: 2 });
const avec = (modification) => () => normaliser({ ...base, ...modification });
const ordre = (...paliers) => paliers.map((palier) => palier.map((critere) => ({ critere, poids: 1 })));
const [ecart, excedent, collisions, horsObjectif, repetees, animateurs, redondance] = CRITERES;
test('chaque code, sur un cas', () => {
assert.deepEqual(ecartsDeRefus([
['un objectif sur une table inconnue', avec({ objectifs: [objectif(99, null, 'ou', ['finance'])] }),
'OBJECTIF_INCONNU', { table: 99 }],
...[0, 3, 1.5, '1'].map((tour) => [
`un objectif au tour ${libelleDe(tour)}`, avec({ objectifs: [objectif(12, tour, 'ou', ['finance'])] }),
'OBJECTIF_TOUR', { table: 12, tour },
]),
['deux objectifs de soirée', avec({ objectifs: [objectif(11, null, 'ou', ['finance']), objectif(11, null, 'et', ['bénévole'])] }),
'OBJECTIF_CONFLIT', { table: 11, tour: null }],
['deux objectifs du tour 2', avec({ objectifs: [objectif(12, 2, 'ou', ['finance']), objectif(12, 2, 'ou', ['finance'])] }),
'OBJECTIF_CONFLIT', { table: 12, tour: 2 }],
['une liste de priorités qui n’est pas une liste', avec({ priorites: 'ordre' }), 'PRIORITES', { faute: 'forme' }],
['un palier vide', avec({ priorites: [[]] }), 'PRIORITES', { faute: 'palierVide', palier: 0 }],
['un poids hors domaine', avec({ priorites: [[{ critere: excedent, poids: 101 }]] }),
'PRIORITES', { faute: 'poids', critere: excedent, palier: 0 }],
['les tours hors objectif joints sous « chacun au moins une fois »',
avec({
modeObjectifs: 'chacun',
priorites: ordre([ecart], [excedent], [collisions, horsObjectif], [repetees], [animateurs], [redondance]),
}),
'PRIORITES', { faute: 'seulASonPalier', critere: horsObjectif, palier: 2 }],
]), []);
});
test('une liste incomplète se complète par l’ordre par défaut avant d’être jugée', () => {
const instance = normaliser({ ...base, priorites: ordre([redondance], [ecart]) });
assert.deepEqual(instance.priorites, ordre([redondance], [ecart], [excedent], [collisions], [horsObjectif], [repetees], [animateurs]));
assert.equal(verifierPriorites(instance.priorites, 'places'), null);
// Le même partage, sous le mode par défaut, est reçu.
const partage = ordre([ecart], [excedent], [collisions, horsObjectif], [repetees], [animateurs], [redondance]);
assert.deepEqual(normaliser({ ...base, priorites: partage }).priorites, partage);
});
test('une forme hors du contrat lève TypeError', () => {
const cas = [
['des objectifs en objet', { objectifs: {} }],
['une règle non résolue', { objectifs: [objectif(11, null, null, ['finance'])] }],
['une règle inconnue', { objectifs: [objectif(11, null, 'toutes', ['finance'])] }],
['des étiquettes d’objectif en texte', { objectifs: [objectif(11, null, 'ou', 'finance')] }],
['une étiquette d’objectif numérique', { objectifs: [objectif(11, null, 'ou', [7])] }],
['des étiquettes de personne en texte', { participants: [etiquete(1, 'finance')] }],
['une étiquette de personne nulle', { participants: [etiquete(1, [null])] }],
['un mode inconnu', { modeObjectifs: 'egal' }],
['une case des objectifs non booléenne', { contraintes: { ...SANS_CONTRAINTE, respecterObjectifs: 'oui' } }],
['une case des animateurs non booléenne', { contraintes: { ...SANS_CONTRAINTE, rencontrerAnimateurs: 1 } }],
['des tours animés en nombre', { participants: [etiquete(1, undefined, 1)], reservations: [tous(1, 11)] }],
['des tours animés décroissants', { participants: [etiquete(1, undefined, [2, 1])], reservations: [tous(1, 11)] }],
['un tour animé répété', { participants: [etiquete(1, undefined, [1, 1])], reservations: [tous(1, 11)] }],
];
const ecarts = [];
for (const [libelle, modification] of cas) {
try {
normaliser({ ...base, ...modification });
ecarts.push(`${libelle} : aucun refus`);
} catch (erreur) {
if (!(erreur instanceof TypeError)) ecarts.push(`${libelle} : ${erreur?.name} au lieu de TypeError`);
}
}
assert.deepEqual(ecarts, []);
});
});
describe('normaliser : état des critères (§ 5.3, A8, D10)', () => {
const etat = (instance, nom) => instance.etatCriteres.find((critere) => critere.nom === nom);
test('un état par critère, dans l’ordre du registre ; sans objectif ni animateur, les deux critères nouveaux sont sans objet', () => {
const instance = normaliser(configuration({ participants: personnes([1, 2]), tables: tablesDe([11], 2), tours: 2 }));
assert.deepEqual(instance.etatCriteres.map(({ nom }) => nom), [...CRITERES]);
assert.deepEqual(etat(instance, 'toursHorsObjectif'), {
nom: 'toursHorsObjectif', coche: true, sansObjet: true, valeurs: ['toursHorsObjectif'],
});
assert.deepEqual(etat(instance, 'animateursNonRencontres'), {
nom: 'animateursNonRencontres', coche: false, sansObjet: true, valeurs: ['animateursNonRencontres'],
});
assert.deepEqual(etat(instance, 'rencontresRepetees'), {
nom: 'rencontresRepetees', coche: false, sansObjet: false, valeurs: ['retoursChoisis'],
});
assert.deepEqual(etat(instance, 'ecartAuPlafondAPrioriMax'), {
nom: 'ecartAuPlafondAPrioriMax', coche: true, sansObjet: false, valeurs: ['ecartAuPlafondAPrioriMax'],
});
});
test('un couple possible donne un objet aux objectifs, une borne non nulle aux animateurs ; le mode choisit les valeurs', () => {
const instance = normaliser({
...configuration({
participants: [etiquete(1, ['finance']), etiquete(2, undefined, [1]), etiquete(3)],
tables: tablesDe([11, 12], 2),
tours: 1,
reservations: [tous(2, 12)],
contraintes: { ...SANS_CONTRAINTE, nouveauxVoisins: true, respecterObjectifs: false, rencontrerAnimateurs: true },
}),
objectifs: [objectif(11, null, 'ou', ['finance'])],
modeObjectifs: 'minimum',
});
assert.equal(instance.modeObjectifs, 'minimum');
assert.deepEqual(etat(instance, 'toursHorsObjectif'), {
nom: 'toursHorsObjectif', coche: false, sansObjet: false, valeurs: ['horsObjectifMax', 'toursHorsObjectif'],
});
assert.deepEqual(etat(instance, 'animateursNonRencontres'), {
nom: 'animateursNonRencontres', coche: true, sansObjet: false, valeurs: ['animateursNonRencontres'],
});
assert.deepEqual(etat(instance, 'rencontresRepetees').valeurs, ['rencontresRepeteesChoisies']);
});
test('un objectif que personne ne porte laisse les objectifs sans objet', () => {
const instance = normaliser({
...configuration({ participants: [etiquete(1, ['finance']), etiquete(2)], tables: tablesDe([11], 2), tours: 1 }),
objectifs: [objectif(11, null, 'et', ['finance', 'Montréal'])],
});
assert.equal(instance.O, 1);
assert.equal(etat(instance, 'toursHorsObjectif').sansObjet, true);
});
});

View file

@ -62,10 +62,15 @@
* @property {number} ancragesImposantRetours personnes dont les réservations
* imposent un retour
* @property {{plancher: number, mobilesAuMoins: number}|null} ecartItineraire
* @property {{horsObjectif: number, jamaisServies: number, horsObjectifMax: number}} objectifsInevitables
* planchers immédiats des trois valeurs des modes des objectifs
* (D16, A10) ; 0 quand rien n'est prouvé, chacun calculé sous tout
* mode
*/
import {
LIBRE,
SANS_GROUPE,
SANS_OBJECTIF,
STATUT,
nombrePlaces,
nombrePlacesManquantes,
@ -248,18 +253,35 @@ function avecTablesManquantes(configuration, instance, planchers, chiffrer) {
return lignes;
}
// La configuration ramenée à ses R′ premiers tours : les réservations de
// portée « tous », qui couvrent alors ses R′ tours, restent, et celles d'un
// tour au-delà de R′ se retirent, exclus compris ; de même les objectifs d'un
// tour au-delà de R′ et les tours animés au-delà de R′. normaliser les
// refuserait (RESERVATION_TOUR, OBJECTIF_TOUR, ANIMATION_NON_FIXEE) ; un tour
// animé gardé reste fixé, sa réservation couvrant un tour de 1 à R′.
function surMoinsDeTours(configuration, tours) {
const avant = (tour) => tour <= tours;
const reduite = {
...configuration,
tours,
reservations: configuration.reservations.filter(({ portee, tour }) => portee !== 'tour' || avant(tour)),
participants: configuration.participants.map((personne) =>
(personne.anime === undefined ? personne : { ...personne, anime: personne.anime.filter(avant) })),
};
if (configuration.objectifs !== undefined) {
reduite.objectifs = configuration.objectifs.filter(({ tour }) => tour === null || tour === undefined || avant(tour));
}
return reduite;
}
// Plus grand R′ < R où la redondance minimale s'annule, null quand elle vaut
// déjà 0 ou null, ou qu'aucun R′ ne l'annule. La sonde à R′ tours garde les
// réservations de portée « tous », qui couvrent alors ses R′ tours, et retire
// celles d'un tour au-delà de R′, exclus compris : normaliser les refuserait
// (RESERVATION_TOUR). Les R′ se sondent du plus grand au plus petit.
// déjà 0 ou null, ou qu'aucun R′ ne l'annule. La sonde à R′ tours est la
// configuration ramenée à ses R′ premiers tours. Les R′ se sondent du plus
// grand au plus petit.
function toursSansRedondance(configuration, R, minimale) {
if (minimale === null || minimale === 0) return null;
for (let tours = R - 1; tours >= 1; tours -= 1) {
const reservations = configuration.reservations.filter(
({ portee, tour }) => portee !== 'tour' || tour <= tours,
);
const sonde = normaliser({ ...configuration, tours, reservations });
const sonde = normaliser(surMoinsDeTours(configuration, tours));
if (redondanceMinimale(sonde, plafondsAPriori(sonde)) === 0) return tours;
}
return null;
@ -359,6 +381,64 @@ function ecartItineraire(configuration, instance, aPriori) {
return { plancher, mobilesAuMoins: Math.ceil(siegesToursInferieurs / R) };
}
// Planchers immédiats des objectifs (D16, A10). Au tour r, une personne
// servie qui n'est pas fixée occupe un siège libre d'une table dont
// l'objectif est en vigueur : P_r compte les couples possibles non fixés,
// L_r ces sièges libres, et les tours hors objectif sont au moins
// Σ_r max(0, P_r − L_r). Un siège libre ne sert qu'une personne par tour :
// parmi C′, les concernées qu'aucune réservation ne sert, au moins
// #C′ − Σ_r min(L_r, P′_r) ne le sont jamais, P′_r comptant celles de C′ dont
// le tour r est possible — tous non fixés, un couple fixé possible étant
// servi. Le plus grand nombre de tours hors objectif d'une personne ne
// descend pas sous la moyenne : le premier plancher divisé par le nombre de
// personnes qui ont un tour possible non fixé, arrondi au-dessus. Les fixés
// ne comptent ni parmi les couples ni parmi les sièges libres. O(T · R + N · R)
// une fois les couples possibles connus.
function objectifsInevitables({ N, T, R, fixe, possible, objectifDe, siegesLibres }) {
let horsObjectif = 0;
let servisAuPlus = 0;
const libresAuTour = new Int32Array(R);
for (let t = 0; t < T; t += 1) {
for (let r = 0; r < R; r += 1) {
if (objectifDe[t * R + r] !== SANS_OBJECTIF) libresAuTour[r] += siegesLibres[t * R + r];
}
}
// Par personne : un couple possible fixé, qui la sert d'office ; un couple
// possible non fixé, qui la fait compter dans P_r, et dans P′_r quand
// aucune réservation ne la sert.
const servieDOffice = new Uint8Array(N);
const aUnTourLibre = new Uint8Array(N);
for (let p = 0; p < N; p += 1) {
for (let r = 0; r < R; r += 1) {
if (possible[p * R + r] === 0) continue;
if (fixe[p * R + r] === LIBRE) aUnTourLibre[p] = 1;
else servieDOffice[p] = 1;
}
}
let concerneesLibres = 0;
let avecTourLibre = 0;
for (let p = 0; p < N; p += 1) {
if (aUnTourLibre[p] === 1) avecTourLibre += 1;
if (aUnTourLibre[p] === 1 && servieDOffice[p] === 0) concerneesLibres += 1;
}
for (let r = 0; r < R; r += 1) {
let couples = 0;
let parmiC = 0;
for (let p = 0; p < N; p += 1) {
if (possible[p * R + r] === 0 || fixe[p * R + r] !== LIBRE) continue;
couples += 1;
if (servieDOffice[p] === 0) parmiC += 1;
}
horsObjectif += Math.max(0, couples - libresAuTour[r]);
servisAuPlus += Math.min(libresAuTour[r], parmiC);
}
return {
horsObjectif,
jamaisServies: Math.max(0, concerneesLibres - servisAuPlus),
horsObjectifMax: horsObjectif === 0 ? 0 : Math.ceil(horsObjectif / avecTourLibre),
};
}
// Plus petite et plus grande valeur, null pour une liste vide.
function etendue(valeurs) {
if (valeurs.length === 0) return { min: null, max: null };
@ -394,6 +474,10 @@ function etendue(valeurs) {
* que mesure tout plan qui honore les réservations (§ 5.4) ;
* ancragesImposantRetours, les personnes qui en portent un au moins.
* - ecartItineraire : plancher de l'écart d'itinéraire maximal, ou null.
* - objectifsInevitables : planchers immédiats des tours hors objectif, des
* personnes jamais servies et du plus de tours hors objectif d'une
* personne, nuls sans objectif ; l'interface écrit ceux que le mode
* compare. animateursMemeAppartenance garde son sens : les ancrés.
*
* @param {import('./types.js').Configuration} configuration
* @param {{planchersCollisions?: typeof planchersCollisions}} [calcul]
@ -426,5 +510,6 @@ export function diagnostiquer(configuration, { planchersCollisions: chiffrer = p
retoursImposes: imposes.totalRetoursImposes,
ancragesImposantRetours: imposes.retoursImposes.filter((retours) => retours > 0).length,
ecartItineraire: ecartItineraire(configuration, instance, aPriori),
objectifsInevitables: objectifsInevitables(instance),
};
}

View file

@ -1091,6 +1091,7 @@ describe('diagnostic : population vide, forme, refus', () => {
retoursImposes: 0,
ancragesImposantRetours: 0,
ecartItineraire: null,
objectifsInevitables: { horsObjectif: 0, jamaisServies: 0, horsObjectifMax: 0 },
};
// Les mêmes chiffres, séparation active ou non : sans présent, aucune
// appartenance n'est portée par chacun, et aucune table n'est forcée.
@ -1438,3 +1439,93 @@ describe('diagnostic : ancrage par table (§ 5.6)', () => {
]);
});
});
describe('diagnostic : les objectifs hors d’atteinte (§ 5.6, A10, D16)', () => {
// n personnes « finance », puis celles que autres ajoute ; la table 1,
// « finance », de huit places ; la table 2, sans objectif, assez grande
// pour les autres ; quatre tours.
function finance(n, { autres = [], reservations = [] } = {}) {
const configuration = salle({ tables: [[1, 8], [2, n + autres.length]], appartenances: fois(n + autres.length, null), tours: 4, reservations });
configuration.participants.forEach((personne, i) => {
if (i < n) personne.etiquettes = ['finance'];
else if (autres[i - n] !== undefined) personne.etiquettes = autres[i - n];
});
configuration.objectifs = [{ table: 1, tour: null, regle: 'ou', etiquettes: ['finance'] }];
return configuration;
}
test('dix, trente, quarante personnes « finance » pour huit places, quatre tours : les planchers de A10', () => {
// Trente : P_r = 30, L_r = 8, 22 × 4 = 88 ; 30 − 4 × 8 < 0 ; ⌈88 / 30⌉ = 3.
// Dix : 2 × 4 = 8 ; aucun ; ⌈8 / 10⌉ = 1. Quarante : 32 × 4 = 128 ;
// 40 − 32 = 8 ; ⌈128 / 40⌉ = 4.
assert.deepEqual(diagnostiquer(finance(30)).objectifsInevitables, { horsObjectif: 88, jamaisServies: 0, horsObjectifMax: 3 });
assert.deepEqual(diagnostiquer(finance(10)).objectifsInevitables, { horsObjectif: 8, jamaisServies: 0, horsObjectifMax: 1 });
assert.deepEqual(diagnostiquer(finance(40)).objectifsInevitables, { horsObjectif: 128, jamaisServies: 8, horsObjectifMax: 4 });
});
test('les fixés ne comptent ni dans les couples ni dans les sièges libres, et qui une réservation sert n’est jamais servi d’office', () => {
// Dix libres, 11 « finance » ancré à la table 1, servi, et 12 sans
// étiquette ancré à la même table : L_r = 6, P_r = 10, 4 × 4 = 16 ; C′
// compte les dix libres, 10 − 24 < 0 ; ⌈16 / 10⌉ = 2.
const deuxAncres = finance(10, { autres: [['finance'], undefined], reservations: [ancrer(11, 1), ancrer(12, 1)] });
assert.deepEqual(diagnostiquer(deuxAncres).objectifsInevitables, { horsObjectif: 16, jamaisServies: 0, horsObjectifMax: 2 });
// Quarante dont 1, ancré à la table 1, servi d'office : L_r = 7, P_r = 39,
// 32 × 4 = 128 ; C′ = 39, 39 − 28 = 11 ; ⌈128 / 39⌉ = 4.
const unAncre = finance(40, { reservations: [ancrer(1, 1)] });
assert.deepEqual(diagnostiquer(unAncre).objectifsInevitables, { horsObjectif: 128, jamaisServies: 11, horsObjectifMax: 4 });
// Quarante dont 1, réservé au seul tour 1 à la table 1 : servi d'office,
// hors de C′, il compte encore dans P_r aux trois autres tours.
// 32 + 3 × 32 = 128 ; C′ = 39, 39 − (7 + 3 × 8) = 8 ; ⌈128 / 40⌉ = 4.
const unTour = finance(40, { reservations: [fixer(1, 1, 1)] });
assert.deepEqual(diagnostiquer(unTour).objectifsInevitables, { horsObjectif: 128, jamaisServies: 8, horsObjectifMax: 4 });
// Dix dont 1 à 6 ancrés à la table 1, servis d'office : L_r = 2, P_r = 4,
// 2 × 4 = 8 ; C′ = 4, 4 − 4 × 2 < 0. Le diviseur compte les quatre qui
// ont un tour possible non fixé, non les six que leur réservation sert :
// ⌈8 / 4⌉ = 2.
const sixAncres = finance(10, { reservations: Array.from({ length: 6 }, (_, i) => ancrer(i + 1, 1)) });
assert.deepEqual(diagnostiquer(sixAncres).objectifsInevitables, { horsObjectif: 8, jamaisServies: 0, horsObjectifMax: 2 });
// Deux tours. 1 à 6, « finance », réservés au tour 2 à la table 1,
// « finance », de huit places ; 7 à 16, « rh », libres ; la table 2,
// « rh » au seul tour 2, de trois places ; la table 3, sans objectif.
// Tour 1 : L_1 = 8, P_1 = 6 (1 à 6), P′_1 = 0, aucun « rh » possible.
// Tour 2 : L_2 = 2 + 3 = 5, P_2 = P′_2 = 10. H = 0 + 5 ; C′ compte les
// dix « rh », 10 − (min(8, 0) + min(5, 10)) = 5, que min(L_r, P_r)
// ramènerait à 0 ; ⌈5 / 16⌉ = 1.
const parTour = salle({ tables: [[1, 8], [2, 3], [3, 16]], appartenances: fois(16, null), tours: 2, reservations: Array.from({ length: 6 }, (_, i) => fixer(i + 1, 1, 2)) });
parTour.participants.forEach((personne, i) => { personne.etiquettes = i < 6 ? ['finance'] : ['rh']; });
parTour.objectifs = [
{ table: 1, tour: null, regle: 'ou', etiquettes: ['finance'] },
{ table: 2, tour: 2, regle: 'ou', etiquettes: ['rh'] },
];
assert.deepEqual(diagnostiquer(parTour).objectifsInevitables, { horsObjectif: 5, jamaisServies: 5, horsObjectifMax: 1 });
});
test('sans objectif, ou sans personne qu’un objectif serve, les trois planchers sont nuls', () => {
const sans = finance(10);
delete sans.objectifs;
const nul = { horsObjectif: 0, jamaisServies: 0, horsObjectifMax: 0 };
assert.deepEqual(diagnostiquer(sans).objectifsInevitables, nul);
const personne = finance(10);
personne.objectifs = [{ table: 1, tour: null, regle: 'ou', etiquettes: ['bénévole'] }];
assert.deepEqual(diagnostiquer(personne).objectifsInevitables, nul);
assert.deepEqual(diagnostiquer(construire('petite')).objectifsInevitables, nul);
});
test('un objectif ou une animation d’un tour au-delà de R′ se retire de la sonde des tours, sans lever', () => {
// La configuration du remède en tours : 1, réservé au tour 4 à la table
// 10, y anime ; la table 20 porte un objectif du tour 4.
const configuration = salle({
tables: [[10, 7], [20, 7], [30, 7], [40, 7]],
appartenances: [
...Array.from({ length: 10 }, (_, g) => fois(2, `Grisaille ${g + 1}`)).flat(),
...Array.from({ length: 8 }, (_, g) => `Club des Merles ${g + 1}`),
],
tours: 4,
reservations: [fixer(1, 10, 4)],
});
configuration.participants[0].anime = [4];
configuration.participants[1].etiquettes = ['finance'];
configuration.objectifs = [{ table: 20, tour: 4, regle: 'ou', etiquettes: ['finance'] }];
assert.deepEqual(diagnostiquer(configuration).remedes, { toursSansRedondance: 3 });
});
});

View file

@ -16,13 +16,23 @@
// moyenne d'une population sans membre.
//
// Une seule lecture du plan décide qu'une paire est réunie, collègue ou revue,
// et qu'une personne revient : parcourirTours. mesurer en tire chacun de ses
// chiffres, et conflitsParTable (conflits.js) le détail par table, si bien
// que la somme des violations par table égale le compte de la mesure
// qu'une personne revient, qu'elle est servie par l'objectif de sa table et
// quels animateurs elle rencontre : parcourirTours. mesurer en tire chacun de
// ses chiffres, et conflitsParTable (conflits.js) le détail par table, si
// bien que la somme des violations par table égale le compte de la mesure
// (§ 13.2).
//
// Un tour servi et une rencontre d'animateur ne se comptent qu'à une table
// que la personne peut occuper à ce tour : sa table fixée, ou, libre, une
// table qui garde un siège libre (§ 4.5). Un plan qui honore les
// réservations n'assied personne ailleurs ; un plan édité qui en déplace
// une garde ainsi s(p) ≤ P(p) et m(p) ≤ B(p), que verifierIndicateurs
// éprouve.
import {
LIBRE,
RESERVE,
SANS_GROUPE,
SANS_OBJECTIF,
exigerPlanIndexe,
replierParPopulation,
} from './configuration.js';
@ -38,6 +48,24 @@ import {
* @property {Agregat} mobiles mobiles et partiellement fixés (§ 5.4)
* @property {Agregat} ancres
*
* @typedef {Object} MesuresObjectifs null sans personne concernée (A9)
* @property {number[]} tours s(p), tours servis, ordre canonique
* @property {number[]} possibles P(p), tours possibles (§ 4.5)
* @property {number} horsObjectif H = Σ (P(p) − s(p))
* @property {number} jamaisServies J, concernés dont s(p) = 0
* @property {number} horsObjectifMax E = max (P(p) − s(p)) sur les concernés
* @property {number} personnesAuMax concernés à E ; 0 quand E = 0
* @property {number} concernes personnes dont P(p) > 0
* @property {TroisAgregats} agregat s(p) sur les concernés
*
* @typedef {Object} MesuresAnimateurs null sans personne concernée (A9)
* @property {number[]} rencontres m(p), animateurs distincts rencontrés
* là où ils animent
* @property {number[]} bornes B(p), instance.borneAnimateurs
* @property {number} nonRencontres Σ (B(p) − m(p)) sur toutes les personnes
* @property {number} concernes personnes dont B(p) > 0
* @property {TroisAgregats} agregat m(p) sur les concernés
*
* @typedef {Object} Mesures
* @property {number[]} rencontres |met(p)|, ordre canonique
* @property {number[]} affilies |F(p)|
@ -72,6 +100,8 @@ import {
* @property {Array<{groupe: string, effectif: number, collisionsCumulees: number,
* pairesDistinctes: number, excedent: number}>} parGroupe
* ordre de instance.groupes
* @property {MesuresObjectifs|null} objectifs
* @property {MesuresAnimateurs|null} animateurs
*/
// Plus grand compte que tient un Uint16Array.
@ -100,35 +130,61 @@ const rangPaire = (N, a, b) => (a * (2 * N - a - 1)) / 2 + (b - a - 1);
* parPaire : les rencontres de chaque paire, rangées par rangPaire ; une
* paire se réunit au plus une fois par tour, R borne chaque compte, qu'un
* Uint16Array tient tant que R ≤ 65 535. toursAssis, et retours — visites
* d'une table déjà visitée, imposées comprises —, par personne.
* d'une table déjà visitée, imposées comprises —, par personne. servis,
* s(p) : les tours où p est assis, à une table qu'il peut occuper, sous un
* objectif en vigueur qui lui correspond. rencontresAnimateurs, m(p) : les
* animateurs distincts, autres que p, qui animent à sa table, à un tour où
* il l'occupe et peut l'occuper, et y sont assis.
*
* Ne modifie ni l'instance ni le plan ; lève ce que lève exigerPlanIndexe.
*
* @param {import('./types.js').Instance} instance
* @param {ArrayLike<number>} tableDe
* @returns {{parTour: Array<Array<{collegues: number[][], revues: number[][], revenus: number[]}>>,
* parPaire: Uint16Array|Uint32Array, toursAssis: number[], retours: number[]}}
* parPaire: Uint16Array|Uint32Array, toursAssis: number[], retours: number[],
* servis: number[], rencontresAnimateurs: number[]}}
*/
export function parcourirTours(instance, tableDe) {
exigerPlanIndexe(instance, tableDe);
const { N, T, R, fixe, groupe } = instance;
const { N, T, R, fixe, groupe, siegesLibres, O, objectifDe, correspond } = instance;
const { A, rangAnimateur, animateurs, debutAnimes, animes } = instance;
// Personne par personne, ses tours dans l'ordre : visites[t] compte ses
// passages à la table t, reserveeAvant[t] note qu'un passage antérieur y
// était réservé. revenu[p × R + r] marque la visite répétée que la
// personne choisit. Le second parcours de ses tours remet à zéro les
// tables qu'elle a visitées : la personne suivante les trouve nulles.
//
// Au même passage, à chaque table que la personne peut occuper : un tour
// servi quand l'objectif en vigueur lui correspond, et chaque animateur qui
// anime là et y est assis, autre qu'elle, rencontré. rencontre[f] vaut p
// quand p a déjà rencontré l'animateur de rang f : la marque, datée par
// personne, n'a pas à se remettre à zéro.
const toursAssis = new Array(N).fill(0);
const retours = new Array(N).fill(0);
const servis = new Array(N).fill(0);
const rencontresAnimateurs = new Array(N).fill(0);
const revenu = new Uint8Array(N * R);
const visites = new Int32Array(T);
const reserveeAvant = new Uint8Array(T);
const rencontre = new Int32Array(A).fill(-1);
for (let p = 0; p < N; p += 1) {
for (let r = 0; r < R; r += 1) {
const t = tableDe[p * R + r];
if (t === RESERVE) continue;
toursAssis[p] += 1;
const reservee = fixe[p * R + r] === t;
const imposee = fixe[p * R + r];
if (imposee === LIBRE ? siegesLibres[t * R + r] > 0 : imposee === t) {
const o = objectifDe[t * R + r];
if (o !== SANS_OBJECTIF && correspond[p * O + o] === 1) servis[p] += 1;
for (let k = debutAnimes[t * R + r]; k < debutAnimes[t * R + r + 1]; k += 1) {
const f = animes[k];
if (f === rangAnimateur[p] || rencontre[f] === p || tableDe[animateurs[f] * R + r] !== t) continue;
rencontre[f] = p;
rencontresAnimateurs[p] += 1;
}
}
const reservee = imposee === t;
if (visites[t] > 0) {
retours[p] += 1;
if (!reservee || reserveeAvant[t] === 0) revenu[p * R + r] = 1;
@ -184,7 +240,7 @@ export function parcourirTours(instance, tableDe) {
}
parTour.push(tables);
}
return { parTour, parPaire, toursAssis, retours };
return { parTour, parPaire, toursAssis, retours, servis, rencontresAnimateurs };
}
// Lecture du triangle des paires, dans l'ordre de rangPaire. Par personne :
@ -296,6 +352,61 @@ function troisAgregats(valeurs, statut, replier = ajouter) {
const somme = (valeurs) => valeurs.reduce((total, valeur) => total + valeur, 0);
// Les objectifs des tables mesurés (A9), sur les personnes concernées, celles
// dont P(p) > 0 : null sans aucune. Pour chacune, P(p) − s(p) entre dans H ;
// s(p) nul, elle est jamais servie ; E et le nombre de personnes à E se
// tiennent en un passage, ce nombre ramené à 0 quand E l'est.
function lireObjectifs({ N, possibles, statut }, servis) {
const tours = [...servis];
const parPersonne = Array.from(possibles);
let concernes = 0;
let horsObjectif = 0;
let jamaisServies = 0;
let horsObjectifMax = 0;
let auMax = 0;
for (let p = 0; p < N; p += 1) {
if (parPersonne[p] === 0) continue;
concernes += 1;
const hors = parPersonne[p] - tours[p];
horsObjectif += hors;
if (tours[p] === 0) jamaisServies += 1;
if (hors > horsObjectifMax) {
horsObjectifMax = hors;
auMax = 0;
}
if (hors === horsObjectifMax) auMax += 1;
}
if (concernes === 0) return null;
return {
tours,
possibles: parPersonne,
horsObjectif,
jamaisServies,
horsObjectifMax,
personnesAuMax: horsObjectifMax === 0 ? 0 : auMax,
concernes,
agregat: troisAgregats(tours.map((s, p) => (parPersonne[p] > 0 ? s : null)), statut, ajouterSiDefinie),
};
}
// Les animateurs rencontrés mesurés (A9), sur les personnes concernées,
// celles dont B(p) > 0 : null sans aucune. Les animateurs non rencontrés
// somment B(p) − m(p) sur toutes les personnes, une non concernée y valant
// zéro.
function lireAnimateurs({ borneAnimateurs, statut }, rencontresAnimateurs) {
const rencontres = [...rencontresAnimateurs];
const bornes = Array.from(borneAnimateurs);
const concernes = bornes.filter((borne) => borne > 0).length;
if (concernes === 0) return null;
return {
rencontres,
bornes,
nonRencontres: somme(bornes) - somme(rencontres),
concernes,
agregat: troisAgregats(rencontres.map((m, p) => (bornes[p] > 0 ? m : null)), statut, ajouterSiDefinie),
};
}
/**
* Mesure complète d'un plan indexé : la mesure qui fait foi (§ 5.10). Ne
* modifie ni l'instance ni le plan. Lève ce que lève exigerPlanIndexe
@ -309,7 +420,7 @@ const somme = (valeurs) => valeurs.reduce((total, valeur) => total + valeur, 0);
*/
export function mesurer(instance, tableDe) {
const { N, groupe, groupes, statut } = instance;
const { parTour, parPaire, toursAssis, retours } = parcourirTours(instance, tableDe);
const { parTour, parPaire, toursAssis, retours, servis, rencontresAnimateurs } = parcourirTours(instance, tableDe);
const paires = lirePaires(instance, parPaire);
const { collisions, distinctes, choisies, retoursChoisis } = lireTours(instance, parTour);
@ -356,5 +467,7 @@ export function mesurer(instance, tableDe) {
totalRetoursChoisis: somme(retoursChoisis),
totalRetoursImposes: somme(retoursImposes),
parGroupe,
objectifs: lireObjectifs(instance, servis),
animateurs: lireAnimateurs(instance, rencontresAnimateurs),
};
}

View file

@ -8,7 +8,8 @@
// indexerPlan comme un plan enregistré.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { indexerPlan, normaliser } from './configuration.js';
import { LIBRE, indexerPlan, normaliser } from './configuration.js';
import { VALEURS_DES_MODES } from './criteres.js';
import { mesurer, parcourirTours } from './indicateurs.js';
const SANS_CONTRAINTE = {
@ -160,6 +161,8 @@ describe('mesurer', () => {
{ groupe: 'B', effectif: 4, collisionsCumulees: 0, pairesDistinctes: 0, excedent: 0 },
{ groupe: 'C', effectif: 4, collisionsCumulees: 0, pairesDistinctes: 0, excedent: 0 },
],
objectifs: null,
animateurs: null,
});
});
@ -641,6 +644,8 @@ describe('mesurer', () => {
totalRetoursChoisis: 0,
totalRetoursImposes: 0,
parGroupe: [],
objectifs: null,
animateurs: null,
});
});
@ -724,6 +729,8 @@ describe('parcourirTours', () => {
parPaire: Uint16Array.of(2, 2, 0, 1, 0, 1),
toursAssis: [3, 3, 3, 2],
retours: [2, 1, 1, 1],
servis: [0, 0, 0, 0],
rencontresAnimateurs: [0, 0, 0, 0],
});
});
@ -758,6 +765,8 @@ describe('parcourirTours', () => {
parPaire: new Uint16Array(0),
toursAssis: [],
retours: [],
servis: [],
rencontresAnimateurs: [],
});
});
@ -769,3 +778,464 @@ describe('parcourirTours', () => {
assert.deepEqual({ tableDe, fixe: instance.fixe, groupe: instance.groupe }, avant);
});
});
// --- Les objectifs des tables et les animateurs (§ 4.5, § 5.4, A9, A21) -----
// Configuration d'épreuve : personnes [id, étiquettes, tours animés], toutes
// présentes ; tables [id, capacité], dans l'ordre de la configuration ;
// objectifs {table, tour, regle, etiquettes}, règle résolue.
function configurer({ personnes, tables, tours, reservations = [], objectifs = [] }) {
return {
participants: personnes.map(([id, etiquettes, anime]) => ({
id,
nom: `P${id}`,
appartenance: null,
...(etiquettes === undefined ? {} : { etiquettes }),
...(anime === undefined ? {} : { anime }),
})),
tables: tables.map(([id, capacite], i) => ({ id, numero: i + 1, capacite })),
tours,
reservations,
contraintes: SANS_CONTRAINTE,
...(objectifs.length === 0 ? {} : { objectifs }),
};
}
// Plan indexé d'un plan écrit par identifiants, une liste par table dans
// l'ordre de configuration.tables, sans réserve.
function indexer(instance, configuration, tours) {
const tables = configuration.tables.map(({ id }) => id);
return indexerPlan(instance, { tables, tours, reserves: tours.map(() => []) });
}
// Les mesures d'un plan, avec l'instance qui les porte.
function mesurerSur(configuration, tours) {
const instance = normaliser(configuration);
return { instance, mesures: mesurer(instance, indexer(instance, configuration, tours)) };
}
// Entiers de debut à fin, inclus.
function suite(debut, fin) {
return Array.from({ length: fin - debut + 1 }, (_, i) => debut + i);
}
// Dix personnes « finance », 1 à 10, six sans étiquette, 11 à 16 ; la table
// 1, « finance », de huit places ; la table 2, sans objectif, de huit
// places ; quatre tours (A21, « chaque mode compare ce qu'il dit »).
const FINANCE = configurer({
personnes: [...suite(1, 10).map((id) => [id, ['finance']]), ...suite(11, 16).map((id) => [id])],
tables: [[1, 8], [2, 8]],
tours: 4,
objectifs: [{ table: 1, tour: null, regle: 'ou', etiquettes: ['finance'] }],
});
// Le plan où la table 1 reçoit, à chaque tour, les « finance » hors des deux
// que chaque paire nomme, et la table 2 ces deux-là avec les six autres.
const tenus = (...paires) => paires.map((ecartes) => [
suite(1, 10).filter((id) => !ecartes.includes(id)),
[...ecartes, ...suite(11, 16)],
]);
describe('mesurer : les objectifs des tables (§ 5.4, A9, A21)', () => {
// Trois plans qui servent tous 32 places. Le premier laisse 10 sans tour
// servi : 1 à 5 en ont 4, 6 à 9 en ont 3. Le deuxième sert chacun une fois
// au moins : 9 et 10 une seule, 1 et 2 trois, 3 à 8 quatre. Le dernier
// sert chacun trois tours au moins : 1 à 8 trois, 9 et 10 quatre.
const PLANS = {
sansTourServi: tenus([9, 10], [8, 10], [7, 10], [6, 10]),
chacunUneFois: tenus([9, 10], [9, 10], [9, 10], [1, 2]),
troisAuMoins: tenus([1, 2], [3, 4], [5, 6], [7, 8]),
};
const attendus = {
sansTourServi: { tours: [4, 4, 4, 4, 4, 3, 3, 3, 3, 0], jamaisServies: 1, horsObjectifMax: 4, personnesAuMax: 1, min: 0 },
chacunUneFois: { tours: [3, 3, 4, 4, 4, 4, 4, 4, 1, 1], jamaisServies: 0, horsObjectifMax: 3, personnesAuMax: 2, min: 1 },
troisAuMoins: { tours: [3, 3, 3, 3, 3, 3, 3, 3, 4, 4], jamaisServies: 0, horsObjectifMax: 1, personnesAuMax: 8, min: 3 },
};
test('chaque plan, compté à la main : tours servis, tours possibles, et les valeurs des trois modes', () => {
for (const [nom, tours] of Object.entries(PLANS)) {
const { objectifs } = mesurerSur(FINANCE, tours).mesures;
const attendu = attendus[nom];
assert.deepEqual(objectifs.tours, [...attendu.tours, 0, 0, 0, 0, 0, 0], nom);
assert.deepEqual(objectifs.possibles, [...new Array(10).fill(4), 0, 0, 0, 0, 0, 0], nom);
assert.equal(objectifs.horsObjectif, 8, nom);
assert.equal(objectifs.jamaisServies, attendu.jamaisServies, nom);
assert.equal(objectifs.horsObjectifMax, attendu.horsObjectifMax, nom);
assert.equal(objectifs.personnesAuMax, attendu.personnesAuMax, nom);
assert.equal(objectifs.concernes, 10, nom);
// Le minimum porte sur les dix concernés, tous mobiles.
assert.deepEqual(objectifs.agregat.tous, { min: attendu.min, moyenne: 3.2, effectif: 10 }, nom);
assert.deepEqual(objectifs.agregat.mobiles, { min: attendu.min, moyenne: 3.2, effectif: 10 }, nom);
assert.deepEqual(objectifs.agregat.ancres, AUCUN, nom);
}
});
test('chaque mode compare ce qu’il dit : égaux sous « places », le premier derrière sous « chacun », le dernier devant sous « minimum »', () => {
// Les valeurs que chaque mode classe, lues sous leur nom de mesure.
const NOM_DE_MESURE = { toursHorsObjectif: 'horsObjectif', jamaisServies: 'jamaisServies', horsObjectifMax: 'horsObjectifMax' };
const cle = (objectifs, mode) => VALEURS_DES_MODES[mode].map((valeur) => objectifs[NOM_DE_MESURE[valeur]]);
const compare = (a, b) => a.reduce((resultat, x, i) => resultat || x - b[i], 0);
const mesures = Object.fromEntries(Object.entries(PLANS).map(([nom, tours]) => [nom, mesurerSur(FINANCE, tours).mesures.objectifs]));
const { sansTourServi: a, chacunUneFois: b, troisAuMoins: c } = mesures;
assert.equal(compare(cle(a, 'places'), cle(b, 'places')), 0);
assert.equal(compare(cle(b, 'places'), cle(c, 'places')), 0);
assert.ok(compare(cle(a, 'chacun'), cle(b, 'chacun')) > 0);
assert.ok(compare(cle(a, 'chacun'), cle(c, 'chacun')) > 0);
assert.ok(compare(cle(c, 'minimum'), cle(a, 'minimum')) < 0);
assert.ok(compare(cle(c, 'minimum'), cle(b, 'minimum')) < 0);
});
test('une personne servie par sa réservation n’est jamais comptée jamais servie', () => {
// 1 est réservé à la table 1 au tour 1, où il est servi, puis tenu à la
// table 2 : un tour servi, trois hors objectif. Seul 10 n'est jamais
// servi.
const reserve = { ...FINANCE, reservations: [{ participant: 1, table: 1, portee: 'tour', tour: 1 }] };
const { objectifs } = mesurerSur(reserve, tenus([9, 10], [1, 10], [1, 10], [1, 10])).mesures;
assert.deepEqual(objectifs.tours.slice(0, 10), [1, 4, 4, 4, 4, 4, 4, 4, 3, 0]);
assert.equal(objectifs.possibles[0], 4);
assert.equal(objectifs.jamaisServies, 1);
assert.equal(objectifs.horsObjectif, 8);
assert.equal(objectifs.horsObjectifMax, 4);
// 1 est partiellement fixé : il compte parmi les mobiles.
assert.equal(objectifs.agregat.mobiles.effectif, 10);
});
test('une personne réservée à une table dont l’objectif ne lui correspond pas n’est pas concernée ; tous servis, le plus grand nombre et le nombre à ce plus grand valent zéro', () => {
// 1, « finance », est réservé aux deux tours à la table 2, « bénévole » :
// aucun tour possible. 2, « finance », et 3, « bénévole », sont servis aux
// deux tours ; 4 ne porte rien.
const configuration = configurer({
personnes: [[1, ['finance']], [2, ['finance']], [3, ['bénévole']], [4]],
tables: [[1, 2], [2, 2]],
tours: 2,
reservations: [{ participant: 1, table: 2, portee: 'tous' }],
objectifs: [
{ table: 1, tour: null, regle: 'ou', etiquettes: ['finance'] },
{ table: 2, tour: null, regle: 'ou', etiquettes: ['bénévole'] },
],
});
const { objectifs } = mesurerSur(configuration, [[[2, 4], [1, 3]], [[2, 4], [1, 3]]]).mesures;
assert.deepEqual(objectifs, {
tours: [0, 2, 2, 0],
possibles: [0, 2, 2, 0],
horsObjectif: 0,
jamaisServies: 0,
horsObjectifMax: 0,
personnesAuMax: 0,
concernes: 2,
agregat: { tous: { min: 2, moyenne: 2, effectif: 2 }, mobiles: { min: 2, moyenne: 2, effectif: 2 }, ancres: AUCUN },
});
});
test('sans personne concernée, les objectifs valent null : aucun objectif, ou un objectif que personne ne porte', () => {
const sans = configurer({ personnes: [[1, ['finance']], [2]], tables: [[1, 2]], tours: 1 });
assert.equal(mesurerSur(sans, [[[1, 2]]]).mesures.objectifs, null);
const personne = { ...sans, objectifs: [{ table: 1, tour: null, regle: 'ou', etiquettes: ['bénévole'] }] };
assert.equal(mesurerSur(personne, [[[1, 2]]]).mesures.objectifs, null);
});
});
// Lecture directe de m(p) et de B(p) (§ 4.5), sans rien du module éprouvé.
// anime(q, r) : q anime au tour r ; la table fixée d'une personne à un tour
// est celle de sa réservation ; une table garde un siège libre quand ses
// réservations n'occupent pas toute sa capacité.
function lectureDesAnimateurs(configuration) {
const { participants, tables, reservations, tours: R } = configuration;
const anime = (q, r) => (q.anime ?? []).includes(r);
const couvre = (reservation, r) => reservation.portee === 'tous' || reservation.tour === r;
const tableFixee = (id, r) => reservations.find((res) => res.participant === id && couvre(res, r))?.table;
const libre = ({ id, capacite }, r) => reservations.filter((res) => res.table === id && couvre(res, r)).length < capacite;
const animentA = (table, r, sauf) =>
participants.filter((q) => q.id !== sauf && anime(q, r) && tableFixee(q.id, r) === table).map((q) => q.id);
const borne = (id) => {
const phi = new Set();
let somme = 0;
for (let r = 1; r <= R; r += 1) {
const fixee = tableFixee(id, r);
const occupables = fixee === undefined ? tables.filter((table) => libre(table, r)).map((table) => table.id) : [fixee];
let plus = 0;
for (const table of occupables) {
const ici = animentA(table, r, id);
for (const f of ici) phi.add(f);
plus = Math.max(plus, ici.length);
}
somme += plus;
}
return Math.min(phi.size, somme);
};
// tours[r][i] : les personnes de la table configuration.tables[i] au tour r + 1.
const rencontres = (id, tours) => {
const vus = new Set();
tours.forEach((listes, r) => {
const liste = listes.find((occupants) => occupants.includes(id));
for (const q of participants) if (q.id !== id && liste.includes(q.id) && anime(q, r + 1)) vus.add(q.id);
});
return vus.size;
};
return { borne, rencontres };
}
// Tous les plans indexés d'une instance qui assoient chacun à chaque tour,
// honorent les réservations et tiennent les capacités ; le même tableau est
// rendu, rempli, à chaque plan.
function* tousLesPlans({ N, T, R, capacite, fixe }) {
const tableDe = new Int32Array(N * R);
const occupation = new Int32Array(T * R);
function* placer(i) {
if (i === N * R) {
yield tableDe;
return;
}
const r = i % R;
for (let t = 0; t < T; t += 1) {
if ((fixe[i] !== LIBRE && fixe[i] !== t) || occupation[t * R + r] === capacite[t]) continue;
occupation[t * R + r] += 1;
tableDe[i] = t;
yield* placer(i + 1);
occupation[t * R + r] -= 1;
}
}
yield* placer(0);
}
describe('mesurer : les animateurs rencontrés (§ 5.4, A9, A21)', () => {
// Tables 31, 32, 33 de trois places, trois tours. 1 anime aux trois tours à
// 31 ; 2 au seul tour 2, à 32 ; 3 et 4 aux trois tours à 33, qui porte deux
// titres ; 5 à 9 sont libres.
const M = configurer({
personnes: [[1, undefined, [1, 2, 3]], [2, undefined, [2]], [3, undefined, [1, 2, 3]], [4, undefined, [1, 2, 3]], ...suite(5, 9).map((id) => [id])],
tables: [[31, 3], [32, 3], [33, 3]],
tours: 3,
reservations: [
{ participant: 1, table: 31, portee: 'tous' },
{ participant: 2, table: 32, portee: 'tour', tour: 2 },
{ participant: 3, table: 33, portee: 'tous' },
{ participant: 4, table: 33, portee: 'tous' },
],
});
// 7 partage la table de 2 au tour 1, où 2 n'anime pas ; 8 la partage au
// tour 3, et y rencontre 1 ; 3 et 4 se rencontrent à chaque tour.
const TOURS_M = [
[[1, 5, 6], [2, 7, 8], [3, 4, 9]],
[[1, 7, 9], [2, 5, 6], [3, 4, 8]],
[[1, 2, 8], [5, 7, 9], [3, 4, 6]],
];
test('m(p), B(p), le minimum et les animateurs non rencontrés égalent une lecture faite couple par couple, tour par tour', () => {
const { borne, rencontres } = lectureDesAnimateurs(M);
const ids = suite(1, 9);
const m = ids.map((id) => rencontres(id, TOURS_M));
const B = ids.map(borne);
const { animateurs } = mesurerSur(M, TOURS_M).mesures;
assert.deepEqual(animateurs.rencontres, m);
assert.deepEqual(animateurs.bornes, B);
assert.equal(animateurs.nonRencontres, B.reduce((total, b, i) => total + b - m[i], 0));
const concernes = ids.filter((_, i) => B[i] > 0);
assert.equal(animateurs.concernes, concernes.length);
const minDe = (garde) => Math.min(...ids.filter((id, i) => B[i] > 0 && garde(id)).map((id) => m[id - 1]));
const ANCRES = [1, 3, 4];
assert.equal(animateurs.agregat.tous.min, minDe(() => true));
assert.equal(animateurs.agregat.mobiles.min, minDe((id) => !ANCRES.includes(id)));
assert.equal(animateurs.agregat.ancres.min, minDe((id) => ANCRES.includes(id)));
// Comptés à la main : 1 ne peut rencontrer personne qui anime (B = 0,
// hors du minimum) ; 7 ne rencontre que 1, 2 n'animant pas au tour 1 ;
// 3 rencontre 4 aux trois tours, compté une fois ; 6 rencontre les quatre.
assert.deepEqual(B, [0, 3, 1, 1, 4, 4, 4, 4, 4]);
assert.deepEqual(m, [0, 1, 1, 1, 2, 4, 1, 3, 3]);
assert.equal(animateurs.concernes, 8);
assert.deepEqual(animateurs.agregat.ancres, { min: 1, moyenne: 1, effectif: 2 });
});
test('la borne des animateurs tient sur tous les plans, et un plan l’atteint pour chacun', () => {
// A : tables 1 et 2 de deux places, trois tours ; 1 anime aux trois tours
// à 1 ; 2 aux tours 1 et 3 à 2. Le plan qui suit atteint B pour chacun :
// tour 1, 1 et 4 | 2 et 3 ; tour 2, 1 et 2 | 3 et 4 ; tour 3, 1 et 3 |
// 2 et 4.
// B : 1 et 9 animent à 1, gelée avec 5, ancré ; 2 anime à 2, qui garde
// deux sièges ; 3 et 4, libres, vont à 2 ou à 3. La borne qui
// oublierait la table fixée de 5 n'y compterait que 2.
// C : 2 et 3 animent à 2 aux deux tours, 1 à 1, 4 au seul tour 1 à 3 :
// une table porte deux animateurs à chaque tour, et qui les rejoint en
// rencontre deux à un même tour.
const configurations = {
A: configurer({
personnes: [[1, undefined, [1, 2, 3]], [2, undefined, [1, 3]], [3], [4]],
tables: [[1, 2], [2, 2]],
tours: 3,
reservations: [
{ participant: 1, table: 1, portee: 'tous' },
{ participant: 2, table: 2, portee: 'tour', tour: 1 },
{ participant: 2, table: 2, portee: 'tour', tour: 3 },
],
}),
B: configurer({
personnes: [[1, undefined, [1, 2]], [2, undefined, [1, 2]], [3], [4], [5], [9, undefined, [1, 2]]],
tables: [[1, 3], [2, 3], [3, 2]],
tours: 2,
reservations: [
{ participant: 1, table: 1, portee: 'tous' },
{ participant: 9, table: 1, portee: 'tous' },
{ participant: 5, table: 1, portee: 'tous' },
{ participant: 2, table: 2, portee: 'tous' },
],
}),
C: configurer({
personnes: [[1, undefined, [1, 2]], [2, undefined, [1, 2]], [3, undefined, [1, 2]], [4, undefined, [1]], [5], [6], [7], [8]],
tables: [[1, 2], [2, 3], [3, 3]],
tours: 2,
reservations: [
{ participant: 1, table: 1, portee: 'tous' },
{ participant: 2, table: 2, portee: 'tous' },
{ participant: 3, table: 2, portee: 'tous' },
{ participant: 4, table: 3, portee: 'tour', tour: 1 },
],
}),
};
const atteintes = {};
for (const [nom, configuration] of Object.entries(configurations)) {
const instance = normaliser(configuration);
let plans = 0;
let atteinte = false;
for (const tableDe of tousLesPlans(instance)) {
plans += 1;
const { animateurs } = mesurer(instance, tableDe);
const depasse = animateurs.rencontres.findIndex((m, p) => m > animateurs.bornes[p]);
assert.equal(depasse, -1, `${nom} : ${tableDe.join(',')}`);
if (animateurs.nonRencontres === 0) atteinte = true;
}
assert.ok(plans > 1, `${nom} : ${plans} plans`);
atteintes[nom] = atteinte;
}
assert.deepEqual(atteintes, { A: true, B: true, C: false });
const A = normaliser(configurations.A);
const plan = indexer(A, configurations.A, [[[1, 4], [2, 3]], [[1, 2], [3, 4]], [[1, 3], [2, 4]]]);
const { animateurs } = mesurer(A, plan);
assert.deepEqual(animateurs.bornes, [0, 1, 2, 2]);
assert.deepEqual(animateurs.rencontres, animateurs.bornes);
// B : 5, ancré à la table gelée, y rencontre ses deux animateurs ; 2
// anime seul à sa table. C : 4, fixé au tour 1 à 3, où il anime seul,
// rejoint au tour 2 au plus deux animateurs ; un libre en rencontre au
// plus trois des quatre en deux tours, sous une borne de quatre.
assert.deepEqual(Array.from(normaliser(configurations.B).borneAnimateurs), [1, 0, 1, 1, 2, 1]);
assert.deepEqual(Array.from(normaliser(configurations.C).borneAnimateurs), [0, 1, 1, 2, 4, 4, 4, 4]);
});
test('un plan qui déplace une personne réservée ne compte ni tour servi ni rencontre hors des tables qu’elle peut occuper', () => {
// 1 anime aux deux tours à la table 1, « finance », que 2, « finance »,
// ancré, remplit avec lui ; 3, « finance », et 4 sont libres. Le plan
// honoré sert 2 aux deux tours et lui fait rencontrer 1, une fois. Le plan
// fautif assied 3 à la table 1, sans siège libre, au tour 1, et 2 à la
// table 2 ; au tour 2, 2 revient à la table 1, que 1 a quittée.
const configuration = configurer({
personnes: [[1, undefined, [1, 2]], [2, ['finance']], [3, ['finance']], [4]],
tables: [[1, 2], [2, 2]],
tours: 2,
reservations: [{ participant: 1, table: 1, portee: 'tous' }, { participant: 2, table: 1, portee: 'tous' }],
objectifs: [{ table: 1, tour: null, regle: 'ou', etiquettes: ['finance'] }],
});
const honore = mesurerSur(configuration, [[[1, 2], [3, 4]], [[1, 2], [3, 4]]]).mesures;
assert.deepEqual(honore.objectifs.tours, [0, 2, 0, 0]);
assert.deepEqual(honore.animateurs.rencontres, [0, 1, 0, 0]);
assert.deepEqual(honore.animateurs.bornes, [0, 1, 0, 0]);
const fautif = mesurerSur(configuration, [[[1, 3], [2, 4]], [[2, 3], [1, 4]]]).mesures;
assert.deepEqual(fautif.objectifs.tours, [0, 1, 0, 0]);
assert.deepEqual(fautif.objectifs.possibles, [0, 2, 0, 0]);
assert.deepEqual(fautif.animateurs.rencontres, [0, 0, 0, 0]);
assert.equal(fautif.animateurs.nonRencontres, 1);
});
test('sans personne concernée, les animateurs valent null : aucun titre pourvu, ou un seul animateur, seul présent', () => {
const sans = configurer({ personnes: [[1], [2]], tables: [[1, 2]], tours: 1 });
assert.equal(mesurerSur(sans, [[[1, 2]]]).mesures.animateurs, null);
const seul = configurer({
personnes: [[1, undefined, [1]]],
tables: [[1, 2]],
tours: 1,
reservations: [{ participant: 1, table: 1, portee: 'tous' }],
});
assert.equal(mesurerSur(seul, [[[1]]]).mesures.animateurs, null);
});
});
// Lecture directe de s(p) (§ 4.5) sur un plan qui honore les réservations,
// sans rien du module éprouvé : l'objectif en vigueur à une table est celui
// du tour s'il existe, sinon celui de la soirée ; « ou » demande une
// étiquette commune, « et » chacune. tours[r][i] : les personnes de la table
// configuration.tables[i] au tour r + 1.
function servisLus(configuration, tours) {
const enVigueur = (table, tour) =>
configuration.objectifs.find((o) => o.table === table && o.tour === tour)
?? configuration.objectifs.find((o) => o.table === table && o.tour === null);
const correspond = (personne, table, tour) => {
const o = enVigueur(table, tour);
if (o === undefined) return false;
const siennes = personne.etiquettes ?? [];
return o.regle === 'ou' ? o.etiquettes.some((e) => siennes.includes(e)) : o.etiquettes.every((e) => siennes.includes(e));
};
return configuration.participants.map((personne) => {
let servis = 0;
tours.forEach((listes, r) => {
const i = listes.findIndex((occupants) => occupants.includes(personne.id));
if (correspond(personne, configuration.tables[i].id, r + 1)) servis += 1;
});
return servis;
});
}
describe('mesurer : un objectif et des sièges libres qui changent d’un tour à l’autre (§ 4.5, A9, A21)', () => {
// Tables 1, 2, 3 de trois places, trois tours. La table 1 porte « finance »
// pour la soirée, « bénévole » au tour 2 ; la table 2, « bénévole » pour la
// soirée, « toutes : finance, Montréal » au tour 3 ; la table 3,
// « finance » au seul tour 1. 5, 6 et 8 réservés au tour 1 gèlent la
// table 2 à ce tour ; 8 y anime ; 7, réservé au tour 2 à la table 2, y
// anime.
const VARIABLE = configurer({
personnes: [
[1, ['finance']],
[2, ['finance', 'Montréal']],
[3, ['bénévole']],
[4, ['bénévole', 'finance']],
[5],
[6, ['Montréal']],
[7, ['finance'], [2]],
[8, ['bénévole'], [1]],
[9, ['finance']],
],
tables: [[1, 3], [2, 3], [3, 3]],
tours: 3,
reservations: [
{ participant: 5, table: 2, portee: 'tour', tour: 1 },
{ participant: 6, table: 2, portee: 'tour', tour: 1 },
{ participant: 8, table: 2, portee: 'tour', tour: 1 },
{ participant: 7, table: 2, portee: 'tour', tour: 2 },
],
objectifs: [
{ table: 1, tour: null, regle: 'ou', etiquettes: ['finance'] },
{ table: 1, tour: 2, regle: 'ou', etiquettes: ['bénévole'] },
{ table: 2, tour: null, regle: 'ou', etiquettes: ['bénévole'] },
{ table: 2, tour: 3, regle: 'et', etiquettes: ['finance', 'Montréal'] },
{ table: 3, tour: 1, regle: 'ou', etiquettes: ['finance'] },
],
});
// Au tour 2, 1 et 9, « finance », s'assoient à la table 1 sous
// « bénévole » ; 3 et 5, libres, à la table 2, gelée au tour 1, où 7 anime.
const TOURS_V = [
[[1, 2, 3], [5, 6, 8], [4, 7, 9]],
[[1, 4, 9], [3, 5, 7], [2, 6, 8]],
[[3, 5, 7], [2, 4, 6], [1, 8, 9]],
];
test('s(p) et m(p) égalent une lecture directe : l’objectif du tour remplace celui de la soirée, la table gelée à un seul tour sert aux autres', () => {
const { mesures } = mesurerSur(VARIABLE, TOURS_V);
const { rencontres } = lectureDesAnimateurs(VARIABLE);
const s = servisLus(VARIABLE, TOURS_V);
const m = suite(1, 9).map((id) => rencontres(id, TOURS_V));
assert.deepEqual(mesures.objectifs.tours, s);
assert.deepEqual(mesures.animateurs.rencontres, m);
// Comptés à la main : 1 et 9 ne sont servis qu'au tour 1 ; 3 l'est au
// tour 2 à la table 2 ; 2 au tour 3 sous « toutes ». 5 rencontre 8 au
// tour 1, 7 au tour 2 ; 3 rencontre 7.
assert.deepEqual(s, [1, 2, 1, 2, 0, 0, 2, 1, 1]);
assert.deepEqual(m, [0, 0, 1, 0, 2, 1, 0, 0, 0]);
});
});

View file

@ -4,8 +4,13 @@
// Contrat de données du moteur : les formes qu'il reçoit et celles qu'il
// rend. Ce module ne porte que des définitions JSDoc et n'exécute rien ; un
// module cite un type par import('./types.js').Instance. Les valeurs que ces
// formes portent — STATUT, LIBRE, RESERVE, SANS_GROUPE — sont définies et
// exportées par configuration.js, leur seul propriétaire.
// formes portent — STATUT, LIBRE, RESERVE, SANS_GROUPE, SANS_OBJECTIF,
// NON_ANIMATEUR — sont définies et exportées par configuration.js, leur seul
// propriétaire.
//
// « Ancré » nomme la personne fixée à une même table à chaque tour (§ 4.2) ;
// « animateur » ne nomme que la personne qui anime à un tour au moins (§ 4.5,
// D6), que portent rangAnimateur et animes.
/**
* @typedef {Object} Participant
@ -16,6 +21,12 @@
* de chaînes fait le groupe (la
* réconciliation est l'affaire du CSV)
* @property {boolean} [exclu] absent = false
* @property {string[]} [etiquettes] absent = aucune ; l'égalité de chaînes
* les compare (§ 4.5)
* @property {number[]} [anime] tours, de 1 à R, strictement croissants,
* où la personne pourvoit un titre, fixée à
* la table du titre (D6) ; absent = n'anime
* à aucun tour
*
* @typedef {Object} Table
* @property {number} id entier ≥ 1, unique
@ -28,11 +39,19 @@
* @property {'tous'|'tour'} portee
* @property {number} [tour] 1..R, requis quand portee === 'tour'
*
* @typedef {Object} Objectif objectif d'une table (§ 4.5)
* @property {number} table id
* @property {number|null} tour 1..R ; null pour la soirée
* @property {'ou'|'et'} regle résolue : la règle propre, sinon le défaut
* @property {string[]} etiquettes
*
* @typedef {Object} Contraintes
* @property {boolean} separerAppartenances
* @property {boolean} nouveauxVoisins
* @property {boolean} nouvelleTable
* @property {boolean} varierAppartenances
* @property {boolean} [respecterObjectifs] absent = vrai (A16)
* @property {boolean} [rencontrerAnimateurs] absent = faux (A16)
*
* @typedef {Object} Configuration
* @property {Participant[]} participants
@ -40,6 +59,10 @@
* @property {number} tours R ≥ 1
* @property {Reservation[]} reservations
* @property {Contraintes} contraintes
* @property {Objectif[]} [objectifs] absent = aucun
* @property {'chacun'|'minimum'} [modeObjectifs] absent = 'places' (D20)
* @property {Array<Array<{critere: string, poids: number}>>} [priorites]
* absent = PRIORITES_PAR_DEFAUT (criteres.js)
*
* @typedef {Object} Plan forme par identifiants (§ 8.9)
* @property {number[]} tables ids de table, dans l'ordre des listes
@ -67,7 +90,38 @@
* @property {Int32Array} ancresParTable a_t
* @property {number} k ancrés
* @property {number} n N − k
* @property {Contraintes} contraintes
* @property {Contraintes} contraintes copie ; les cases facultatives n'y
* figurent que si la configuration les porte
* @property {Int32Array} siegesLibres [t * R + r] → c_t moins les personnes
* que fixe y assied au tour r
* @property {number} O objectifs distincts : même règle, mêmes
* étiquettes dans le même ordre ; numérotés
* dans l'ordre des tables par identifiant
* croissant, la soirée puis les tours
* @property {Int32Array} objectifDe [t * R + r] → objectif distinct en vigueur,
* celui du tour sinon celui de la soirée ;
* SANS_OBJECTIF (−1) sans objectif
* @property {Uint8Array} correspond [p * O + o] → 1 quand p correspond à o
* (correspondA)
* @property {Uint8Array} possible [p * R + r] → 1 quand le couple (p, r) est
* possible (§ 4.5)
* @property {Int32Array} possibles P(p), tours possibles de chacun
* @property {'places'|'chacun'|'minimum'} modeObjectifs
* @property {number} A animateurs
* @property {Int32Array} rangAnimateur par index participant : rang 0..A − 1,
* dans l'ordre des ids ; NON_ANIMATEUR (−1)
* @property {Int32Array} animateurs rang → index participant
* @property {Int32Array} debutAnimes T × R + 1 cases : la ligne t * R + r
* de animes va de debutAnimes[t * R + r] à
* debutAnimes[t * R + r + 1]
* @property {Int32Array} animes rangs des animateurs qui animent à la table
* t au tour r, croissants, ligne par ligne
* @property {Int32Array} borneAnimateurs B(p) = min(#Φ(p), Σ_r φ_r(p)) (D21)
* @property {Array<{nom: string, coche: boolean, sansObjet: boolean,
* valeurs: string[]}>} etatCriteres
* un état par critère, dans l'ordre de CRITERES (criteres.js)
* @property {Array<Array<{critere: string, poids: number}>>} priorites
* la liste complète (completerPriorites), valide sous le mode
*
* Un plan indexé est un Int32Array `tableDe` de longueur N × R :
* tableDe[p * R + r] = index de table, RESERVE (−1) pour la réserve.

View file

@ -14,9 +14,9 @@ import { LIBRE } from './configuration.js';
* @typedef {{code: string, participant?: number, table?: number, tour?: number,
* detail?: string}} Violation ids, tours numérotés à partir de 1
*
* @typedef {{code: string, index: number}} ViolationIndicateur
* @typedef {{code: string, index?: number}} ViolationIndicateur
* index : rang de la personne dans l'ordre canonique, celui de
* instance.ids
* instance.ids ; absent d'une violation qui porte sur un total
*/
// Index de table d'une liste que plan.tables ne rattache à aucune table de
@ -206,6 +206,20 @@ function exigerLongueur(valeurs, N, nom, attendu) {
}
}
// Les dépassements des objectifs et des animateurs d'une personne (§ 5.4,
// A9), ajoutés à violations : s(p) ≤ P(p), s(p) ≤ tours assis, m(p) ≤ B(p).
// P(p) ne se compare pas aux tours assis : un plan édité qui laisse une
// personne en réserve à un tour possible tient P(p) au-delà.
function verifierPersonne(p, { toursAssis, objectifs, animateurs }, violations) {
if (objectifs !== null) {
if (!tient(objectifs.possibles[p], objectifs.tours[p])) violations.push({ code: 'TOURS_SERVIS', index: p });
if (!tient(toursAssis[p], objectifs.tours[p])) violations.push({ code: 'TOURS_SERVIS_NON_ASSIS', index: p });
}
if (animateurs !== null && !tient(animateurs.bornes[p], animateurs.rencontres[p])) {
violations.push({ code: 'ANIMATEURS_RENCONTRES', index: p });
}
}
/**
* Garde du § 12.3 : éprouve, pour chacun, la ligne de décomposition du
* § 12.10.5, N − 1 ≥ plafond a priori ≥ plafond réalisé ≥ rencontres, une
@ -213,6 +227,15 @@ function exigerLongueur(valeurs, N, nom, attendu) {
* toute la ligne. Une violation désigne un calcul faux, sur lequel le profil
* ne se dessine pas.
*
* Quand les mesures portent les objectifs des tables ou les animateurs, non
* null (A9), elle éprouve aussi, pour chacun, les tours servis contre ses
* tours possibles (TOURS_SERVIS) et ses tours assis (TOURS_SERVIS_NON_ASSIS),
* les animateurs rencontrés contre la borne (ANIMATEURS_RENCONTRES) ; puis,
* sur les totaux, sans index, les personnes jamais servies contre les
* concernées (JAMAIS_SERVIES) et le plus de tours hors objectif d'une
* personne contre le plus de tours possibles (HORS_OBJECTIF_MAX). Une mesure
* absente se lit comme null : seule la liste rencontres est exigée.
*
* Les trois listes suivent l'ordre canonique, celui de instance.ids, et N est
* la longueur de mesures.rencontres. plafondsAPriori vaut null quand le
* plafond a priori est inconnu (§ 12.6) : la ligne perd ce terme, et seule la
@ -225,17 +248,20 @@ function exigerLongueur(valeurs, N, nom, attendu) {
* (rencontres > réalisé). La fonction ne reçoit pas les identifiants : une
* violation désigne la personne par index, son rang dans l'ordre canonique,
* que instance.ids[index] traduit. Les violations se rangent par index
* croissant, puis de gauche à droite sur la ligne.
* croissant, puis de gauche à droite sur la ligne, suivie des dépassements
* de la personne ; les deux violations des totaux viennent en dernier.
*
* Lève RangeError quand une liste de plafonds n'a pas N cases : elle décrit
* Lève RangeError quand une liste de plafonds, ou une liste par personne des
* objectifs, des animateurs ou des tours assis, n'a pas N cases : elle décrit
* une autre population, et l'écart est une faute de code. Lève TypeError
* quand plafondsRealises n'est pas une liste, ou plafondsAPriori ni une liste
* ni null : seul null dit l'a priori inconnu. undefined, la valeur d'un champ
* manquant, ne passe pas pour lui : il ôterait deux inégalités à la garde
* sans rien signaler.
*
* @param {{rencontres: ArrayLike<number>}} mesures les mesures d'un plan ;
* seule la liste rencontres est lue
* @param {{rencontres: ArrayLike<number>, toursAssis?: ArrayLike<number>,
* objectifs?: Object|null, animateurs?: Object|null}} mesures
* les mesures d'un plan ; toursAssis n'est lu qu'avec les objectifs
* @param {ArrayLike<number>|null} plafondsAPriori
* @param {ArrayLike<number>} plafondsRealises
* @returns {ViolationIndicateur[]}
@ -245,6 +271,16 @@ export function verifierIndicateurs(mesures, plafondsAPriori, plafondsRealises)
const N = rencontres.length;
exigerLongueur(plafondsRealises, N, 'plafondsRealises', 'liste');
if (plafondsAPriori !== null) exigerLongueur(plafondsAPriori, N, 'plafondsAPriori', 'liste ou null');
const lues = { toursAssis: mesures.toursAssis, objectifs: mesures.objectifs ?? null, animateurs: mesures.animateurs ?? null };
if (lues.objectifs !== null) {
exigerLongueur(lues.objectifs.tours, N, 'objectifs.tours', 'liste');
exigerLongueur(lues.objectifs.possibles, N, 'objectifs.possibles', 'liste');
exigerLongueur(lues.toursAssis, N, 'toursAssis', 'liste');
}
if (lues.animateurs !== null) {
exigerLongueur(lues.animateurs.rencontres, N, 'animateurs.rencontres', 'liste');
exigerLongueur(lues.animateurs.bornes, N, 'animateurs.bornes', 'liste');
}
const violations = [];
for (let p = 0; p < N; p += 1) {
const realise = plafondsRealises[p];
@ -256,6 +292,13 @@ export function verifierIndicateurs(mesures, plafondsAPriori, plafondsRealises)
if (!tient(aPriori, realise)) violations.push({ code: 'ORDRE_PLAFONDS', index: p });
}
if (!tient(realise, rencontres[p])) violations.push({ code: 'DEPASSEMENT', index: p });
verifierPersonne(p, lues, violations);
}
if (lues.objectifs !== null) {
const { jamaisServies, concernes, horsObjectifMax, possibles } = lues.objectifs;
if (!tient(concernes, jamaisServies)) violations.push({ code: 'JAMAIS_SERVIES' });
const plusDePossibles = Array.prototype.reduce.call(possibles, (plus, n) => Math.max(plus, n), 0);
if (!tient(plusDePossibles, horsObjectifMax)) violations.push({ code: 'HORS_OBJECTIF_MAX' });
}
return violations;
}

View file

@ -24,6 +24,8 @@
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { indexerPlan, normaliser } from './configuration.js';
import { mesurer } from './indicateurs.js';
import { plafondsAPriori, plafondsRealises } from './plafond.js';
import { ErreurConfiguration } from './erreurs.js';
import { verifierIndicateurs, verifierInvariants } from './verification.js';
@ -827,3 +829,86 @@ describe('verifierIndicateurs : la ligne de décomposition (§ 12.3, § 12.10.5)
}
});
});
describe('verifierIndicateurs : les objectifs et les animateurs (§ 5.4, A9)', () => {
// Quatre personnes, deux tours : 1 et 2 ont deux tours possibles, 2 en a
// un servi, 3 et 4 n'en ont aucun ; la borne des animateurs vaut 1 pour
// chacun, et 4 seul en rencontre un. Chaque inégalité y tient.
const base = () => ({
rencontres: [3, 3, 3, 3],
toursAssis: [2, 2, 2, 2],
objectifs: { tours: [0, 1, 0, 0], possibles: [2, 2, 0, 0], jamaisServies: 1, horsObjectifMax: 2, concernes: 2 },
animateurs: { rencontres: [0, 0, 0, 1], bornes: [1, 1, 1, 1] },
});
const plafonds = () => [3, 3, 3, 3];
// La base dont modifier(mesures) a changé une valeur.
const avec = (modifier) => {
const mesures = base();
modifier(mesures);
return mesures;
};
test('les mesures d’un plan tiennent : la base, et un plan mesuré par le moteur', () => {
assert.deepEqual(verifierIndicateurs(base(), plafonds(), plafonds()), []);
const instance = normaliser({
participants: [
{ id: 1, nom: 'P1', appartenance: null, anime: [1, 2] },
{ id: 2, nom: 'P2', appartenance: null, etiquettes: ['finance'] },
{ id: 3, nom: 'P3', appartenance: null, etiquettes: ['finance'] },
{ id: 4, nom: 'P4', appartenance: null },
],
tables: [{ id: 1, numero: 1, capacite: 2 }, { id: 2, numero: 2, capacite: 2 }],
tours: 2,
reservations: [{ participant: 1, table: 1, portee: 'tous' }],
contraintes: SANS_CONTRAINTE,
objectifs: [{ table: 1, tour: null, regle: 'ou', etiquettes: ['finance'] }],
});
const tableDe = indexerPlan(instance, { tables: [1, 2], tours: [[[1, 2], [3, 4]], [[1, 4], [2, 3]]], reserves: [[], []] });
const mesures = mesurer(instance, tableDe);
assert.equal(mesures.objectifs.concernes, 2);
assert.equal(mesures.animateurs.concernes, 3);
assert.deepEqual(verifierIndicateurs(mesures, plafondsAPriori(instance), plafondsRealises(instance, tableDe)), []);
});
test('chaque dépassement a son code, et aucun autre ne tombe', () => {
const cas = [
['tours servis 3 > tours possibles 2', avec((m) => { m.objectifs.tours[1] = 3; m.toursAssis[1] = 3; }),
[{ code: 'TOURS_SERVIS', index: 1 }]],
['tours servis 1 > tours assis 0', avec((m) => { m.toursAssis[1] = 0; }),
[{ code: 'TOURS_SERVIS_NON_ASSIS', index: 1 }]],
['animateurs rencontrés 2 > borne 1', avec((m) => { m.animateurs.rencontres[3] = 2; }),
[{ code: 'ANIMATEURS_RENCONTRES', index: 3 }]],
['jamais servies 3 > concernées 2', avec((m) => { m.objectifs.jamaisServies = 3; }),
[{ code: 'JAMAIS_SERVIES' }]],
['le plus de tours hors objectif 3 > tours possibles 2', avec((m) => { m.objectifs.horsObjectifMax = 3; }),
[{ code: 'HORS_OBJECTIF_MAX' }]],
].map(([libelle, mesures, attendu]) => [libelle, () => verifierIndicateurs(mesures, plafonds(), plafonds()), attendu]);
assert.deepEqual(ecarts(cas), []);
});
test('les violations d’une personne suivent celles de sa ligne, les deux globales viennent en dernier', () => {
const mesures = avec((m) => {
m.rencontres[1] = 4;
m.objectifs.tours[1] = 3;
m.toursAssis[1] = 3;
m.animateurs.rencontres[1] = 2;
m.objectifs.jamaisServies = 3;
m.objectifs.horsObjectifMax = 3;
});
assert.deepEqual(verifierIndicateurs(mesures, plafonds(), plafonds()), [
{ code: 'DEPASSEMENT', index: 1 },
{ code: 'TOURS_SERVIS', index: 1 },
{ code: 'ANIMATEURS_RENCONTRES', index: 1 },
{ code: 'JAMAIS_SERVIES' },
{ code: 'HORS_OBJECTIF_MAX' },
]);
});
test('des objectifs et des animateurs null, ou absents, ne sont pas lus ; une liste d’une autre longueur lève RangeError', () => {
assert.deepEqual(verifierIndicateurs({ ...base(), objectifs: null, animateurs: null }, plafonds(), plafonds()), []);
assert.deepEqual(verifierIndicateurs({ rencontres: [3, 3, 3, 3] }, plafonds(), plafonds()), []);
assert.throws(() => verifierIndicateurs(avec((m) => { m.objectifs.possibles.pop(); }), plafonds(), plafonds()), RangeError);
assert.throws(() => verifierIndicateurs(avec((m) => { m.toursAssis.pop(); }), plafonds(), plafonds()), RangeError);
assert.throws(() => verifierIndicateurs(avec((m) => { m.animateurs.bornes.push(1); }), plafonds(), plafonds()), RangeError);
});
});

View file

@ -19,7 +19,8 @@
// FORMAT_INCONNU s'il n'est pas un entier ≥ 1) ; la forme de chaque champ,
// dans l'ordre du schéma puis des rangs (FORME) ; les identifiants des
// participants et des tables, le tour des réservations, la portée et le tour
// des objectifs de table, et les titres répétés à l'identique (FORME) ; les
// des objectifs de table, l'ordre des priorités sous le mode des objectifs,
// et les titres répétés à l'identique (FORME) ; les
// comptes de la saisie dans l'en-tête — participants, tables, réservations,
// titres — (COMPTES) ; les références (REFERENCE). Les comptes des
// propositions et du retenu ne refusent jamais le fichier (§ 8.9, point 3) :
@ -40,13 +41,14 @@
// Un fichier d'un format plus ancien se lit comme un fichier du format
// courant : chaque format n'ajoute au précédent que des champs facultatifs
// (§ 8.8, § 8.9). Le format 2 ajoute les étiquettes des personnes, les
// objectifs des tables et quatre réglages, chacun absent à son défaut ; un
// objectifs des tables et cinq réglages, chacun absent à son défaut ; un
// fichier du format 1 ne les porte pas, et se lit sans conversion. Il ne se
// réécrit qu'au premier geste, au format courant.
//
// Les autres fonctions créent une charge ou dérivent d'elle la configuration
// du moteur, l'état qu'impose son contenu, la capacité d'une table et la
// valeur d'un réglage facultatif. Aucune ne modifie ce qu'elle reçoit.
import { CRITERES, POIDS_MAX, PRIORITES_PAR_DEFAUT, completerPriorites, verifierPriorites } from '../moteur/criteres.js';
import { HISTORIQUE_PAR_DEFAUT } from '../moteur/recherche.js';
import { ETIQUETTES_MAX, estEtiquetteBienFormee } from './etiquettes.js';
import { ErreurStockage } from './erreurs.js';
@ -253,6 +255,26 @@ const OBJECTIF = objet([
// défaut, il est une faute de forme.
const horsDuDefaut = (...valeurs) => regle('parmi', { valeurs: Object.freeze(valeurs), optionnel: true });
// Les critères d'un palier de priorités se rangent dans l'ordre du registre
// (A16) ; à critère égal, que l'analyse refuse, le poids puis le JSON les
// départagent : l'ordre reste total.
const rangDuCritere = (nom) => CRITERES.indexOf(nom);
const ordreDansLePalier = (a, b) =>
rangDuCritere(a.critere) - rangDuCritere(b.critere) ||
a.poids - b.poids ||
comparerTextes(JSON.stringify(a), JSON.stringify(b));
// L'ordre des priorités (§ 5.7, A16) : une liste non vide de paliers, chacun
// une liste non vide d'entrées {critere, poids}, le critère nommé par le
// registre, le poids entier de 1 à POIDS_MAX ; chaque palier s'écrit rangé.
// La répétition, l'absence, les critères seuls à leur palier sous le mode
// des objectifs et l'ordre par défaut écrit se jugent sur la charge entière
// (examinerPriorites).
const PRIORITES = liste(
liste(objet([['critere', parmi(...CRITERES)], ['poids', entier(1, POIDS_MAX)]]), { min: 1, tri: ordreDansLePalier }),
{ min: 1, optionnel: true },
);
const REGLAGES = objet([
['separerAppartenances', BOOLEEN],
['nouveauxVoisins', BOOLEEN],
@ -266,12 +288,14 @@ const REGLAGES = objet([
// « et » seulement (regleObjectifs) ; la case des objectifs, décochée
// seulement (respecterObjectifs) ; le mode des objectifs hors du plus de
// places servies (modeObjectifs) ; la case des animateurs, cochée
// seulement (rencontrerAnimateurs).
// seulement (rencontrerAnimateurs) ; l'ordre des priorités hors de l'ordre
// par défaut (priorites).
['cibleRedondance', entier(0, Infinity, { optionnel: true })],
['regleObjectifs', horsDuDefaut('et')],
['respecterObjectifs', horsDuDefaut(false)],
['modeObjectifs', horsDuDefaut('chacun', 'minimum')],
['rencontrerAnimateurs', horsDuDefaut(true)],
['priorites', PRIORITES],
]);
const PARTICIPANT = objet([
@ -678,6 +702,37 @@ function examinerObjectifs({ evenement, tables }, faute) {
});
}
// L'ordre des priorités, quand les réglages le portent, chaque palier rangé
// dans l'ordre du registre : la liste se juge par verifierPriorites sous le
// mode des objectifs de la charge, et l'ordre par défaut écrit est une faute
// (§ 8.9, A16). Une faute d'un palier désigne ce palier ; une autre, la
// liste. faute(chemin) lève l'écart.
function examinerPriorites(charge, faute) {
const { priorites: portee } = charge.reglages;
if (portee === undefined) return;
const chemin = 'charge.reglages.priorites';
const rangee = rangerPriorites(portee);
const ecart = verifierPriorites(rangee, modeObjectifs(charge));
if (ecart !== null) faute(ecart.palier === undefined ? chemin : `${chemin}[${ecart.palier}]`);
if (egaleAuDefaut(rangee)) faute(chemin);
}
// Copie d'une liste de priorités, chaque palier rangé dans l'ordre du
// registre, chaque entrée réduite à {critere, poids}.
const rangerPriorites = (liste) =>
liste.map((palier) => palier.map(({ critere, poids }) => ({ critere, poids })).sort(ordreDansLePalier));
// Vrai quand une liste rangée est l'ordre par défaut : chaque critère seul à
// son palier, au poids 1, dans l'ordre du registre.
const egaleAuDefaut = (liste) =>
liste.length === PRIORITES_PAR_DEFAUT.length &&
liste.every(
(palier, rang) =>
palier.length === 1 &&
palier[0].critere === PRIORITES_PAR_DEFAUT[rang][0].critere &&
palier[0].poids === PRIORITES_PAR_DEFAUT[rang][0].poids,
);
// Comptes de l'en-tête qui portent sur les placements : une proposition ou
// un retenu perdus se recalculent, la saisie non.
const COMPTES_DES_PLACEMENTS = new Set(['propositions', 'retenu']);
@ -770,6 +825,7 @@ export function analyser(texte) {
examinerIdentifiants(charge, faute);
examinerTours(charge, faute);
examinerObjectifs(charge, faute);
examinerPriorites(charge, faute);
examinerTitres(charge, faute);
const manquants = examinerComptes(entete.comptes, charge, plus);
examinerReferences(charge, plus);
@ -784,18 +840,23 @@ export function analyser(texte) {
* quatre contraintes des réglages et les deux cases facultatives ; les
* objectifs des tables, chacun { table, tour, regle, etiquettes }, tour null
* pour la soirée, sa règle résolue : la sienne, sinon la règle par défaut
* (regleObjectifs) ; le mode des objectifs. Chaque champ nouveau du format 2
* est absent à son défaut — étiquettes d'une personne qui n'en porte pas,
* objectifs quand aucune table n'en a, modeObjectifs à « places »,
* respecterObjectifs coché, rencontrerAnimateurs décoché — : la
* configuration d'une charge sans eux est celle du format 1. Les tables vont
* (regleObjectifs) ; le mode des objectifs ; l'ordre des priorités, tel que
* le rend priorites. Chaque champ nouveau du format 2 est absent à son
* défaut — étiquettes d'une personne qui n'en porte pas, objectifs quand
* aucune table n'en a, modeObjectifs à « places », respecterObjectifs coché,
* rencontrerAnimateurs décoché, priorites quand les réglages ne le portent
* pas — : la configuration d'une charge sans eux est celle du format 1. Qui
* anime à chaque tour ne se lit pas ici : configurationDe
* (src/application/configuration.js) l'ajoute, sur la règle des titres
* pourvus, que le stockage n'importe pas. Les tables vont
* par identifiant croissant, l'ordre du texte canonique : le moteur assoit
* table après table, et une charge aux tables dans un autre ordre, relue
* après un enregistrement, ne régénérerait plus ses propositions (§ 8.9).
* Les objectifs suivent l'ordre des tables, puis la soirée et les tours
* croissants. Les participants et les réservations gardent l'ordre de la
* charge : le rang d'une réservation que nomme une ErreurConfiguration est
* son rang dans la charge. Les listes d'étiquettes sont des copies.
* son rang dans la charge. Les listes d'étiquettes et de priorités sont des
* copies.
*
* @param {import('./types.js').Charge} charge
* @returns {import('../moteur/types.js').Configuration}
@ -834,6 +895,7 @@ export function configurationDepuisCharge(charge) {
if (objectifs.length > 0) configuration.objectifs = objectifs;
const mode = modeObjectifs(charge);
if (mode !== MODE_OBJECTIFS_PAR_DEFAUT) configuration.modeObjectifs = mode;
if (reglages.priorites !== undefined) configuration.priorites = priorites(charge);
return configuration;
}
@ -919,6 +981,23 @@ export function modeObjectifs(charge) {
return charge.reglages.modeObjectifs ?? MODE_OBJECTIFS_PAR_DEFAUT;
}
/**
* L'ordre des priorités de l'événement (§ 5.7, § 8.9, A16) : la liste que
* les réglages portent, sinon l'ordre par défaut. Chaque palier y est rangé
* dans l'ordre du registre, et un critère que la liste ne nomme pas — écrit
* sous un format qui ne le connaissait pas — y prend sa place de l'ordre par
* défaut, seul à son palier, au poids 1 (completerPriorites). C'est la seule
* lecture de ce réglage. Rend une liste neuve à chaque appel ; ne modifie pas
* la charge.
*
* @param {import('./types.js').Charge} charge
* @returns {Array<Array<{critere: string, poids: number}>>}
*/
export function priorites(charge) {
const { priorites: portee } = charge.reglages;
return portee === undefined ? rangerPriorites(PRIORITES_PAR_DEFAUT) : completerPriorites(rangerPriorites(portee));
}
/**
* Capacité courante d'une table (§ 6.1) : ses sièges quand elle est
* surchargée, le défaut de l'événement quand elle le suit (sieges null).

View file

@ -14,6 +14,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from '../../test/lanceur.js';
import { STATUT, normaliser } from '../moteur/configuration.js';
import { CRITERES, PRIORITES_PAR_DEFAUT } from '../moteur/criteres.js';
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { HISTORIQUE_PAR_DEFAUT } from '../moteur/recherche.js';
import { texteLivre } from '../demo/livrees.js';
@ -36,6 +37,7 @@ import {
modeObjectifs,
premiereFaute,
premiereFauteEnPlace,
priorites,
regleObjectifs,
} from './document.js';
import { ErreurStockage } from './erreurs.js';
@ -1347,13 +1349,15 @@ describe('les champs facultatifs du format 2 : forme et place (§ 8.9, A16)', ()
const PARTICIPANT = regleDuChamp(CHARGE_SCHEMA, 'participants').element;
const TABLE = regleDuChamp(CHARGE_SCHEMA, 'tables').element;
assert.deepEqual(
REGLAGES.champs.slice(-5).map(([cle, regle]) => [cle, regle.optionnel]),
REGLAGES.champs.slice(-7).map(([cle, regle]) => [cle, regle.optionnel]),
[
['generation', false],
['cibleRedondance', true],
['regleObjectifs', true],
['respecterObjectifs', true],
['modeObjectifs', true],
['rencontrerAnimateurs', true],
['priorites', true],
],
);
assert.deepEqual(PARTICIPANT.champs.slice(-2).map(([cle, regle]) => [cle, regle.optionnel]), [['exclu', false], ['etiquettes', true]]);
@ -1677,3 +1681,188 @@ describe('configurationDepuisCharge : étiquettes, objectifs, mode et cases (con
assert.deepEqual(normaliser(configuration).ids, [1, 2, 5]);
});
});
// L'ordre par défaut, chaque critère seul à son palier, au poids 1 : une
// liste neuve.
const prioritesParDefaut = () => CRITERES.map((critere) => [{ critere, poids: 1 }]);
// L'ordre par défaut où second rejoint le palier de premier, rangé dans
// l'ordre du registre, chacun à son poids : une liste neuve.
function avecPalierPartage(premier, second, [poidsPremier, poidsSecond] = [1, 3]) {
const liste = prioritesParDefaut().filter(([{ critere }]) => critere !== second);
const palier = liste.find(([{ critere }]) => critere === premier);
palier[0].poids = poidsPremier;
palier.push({ critere: second, poids: poidsSecond });
palier.sort((a, b) => CRITERES.indexOf(a.critere) - CRITERES.indexOf(b.critere));
return liste;
}
// L'ordre réglé des épreuves : l'excédent de collisions × 1 et les tours
// hors objectif × 3 au deuxième palier, les autres critères seuls.
const prioritesReglees = () => avecPalierPartage('excedentCollisions', 'toursHorsObjectif');
describe('l’ordre des priorités : un réglage facultatif, absent à son défaut (§ 8.9, A16)', () => {
test('une liste réglée se lit telle qu’écrite ; elle s’écrit en dernier des réglages, compacte, et se relit à l’identique', () => {
const texte = abime((d) => {
d.charge.reglages.rencontrerAnimateurs = true;
d.charge.reglages.priorites = prioritesReglees();
});
const lu = analyser(texte);
assert.deepEqual(lu.charge.reglages.priorites, prioritesReglees());
const ecrit = serialiser(lu.charge, lu.entete);
const attendu = `"rencontrerAnimateurs":true,"priorites":${JSON.stringify(prioritesReglees())}}`;
assert.ok(ecrit.includes(attendu), ecrit);
assert.ok(attendu.includes('[{"critere":"excedentCollisions","poids":1},{"critere":"toursHorsObjectif","poids":3}]'), attendu);
assert.equal(serialiser(analyser(ecrit).charge, lu.entete), ecrit);
});
test('un palier hors de l’ordre du registre se lit ; il s’écrit rangé, et ses lecteurs le rendent rangé', () => {
const desordre = prioritesReglees();
desordre[1].reverse();
const lu = analyser(abime((d) => { d.charge.reglages.priorites = desordre; }));
assert.deepEqual(lu.charge.reglages.priorites, desordre);
assert.deepEqual(priorites(lu.charge), prioritesReglees());
assert.deepEqual(configurationDepuisCharge(lu.charge).priorites, prioritesReglees());
const ecrit = serialiser(lu.charge, lu.entete);
assert.deepEqual(analyser(ecrit).charge.reglages.priorites, prioritesReglees());
});
test('variantes admises : un palier de trois critères pondérés, les animateurs avant l’excédent, les tours hors objectif seuls sous « chacun » et « l’équité d’abord »', () => {
const trois = avecPalierPartage('excedentCollisions', 'toursHorsObjectif', [100, 7]);
trois.splice(trois.findIndex(([{ critere }]) => critere === 'collisionsCumulees'), 1);
trois[1].splice(1, 0, { critere: 'collisionsCumulees', poids: 2 });
const animateursDAbord = prioritesParDefaut();
animateursDAbord.splice(1, 0, ...animateursDAbord.splice(5, 1));
const seuls = prioritesParDefaut().reverse();
seuls.push(...seuls.splice(seuls.findIndex(([{ critere }]) => critere === 'ecartAuPlafondAPrioriMax'), 1));
const variantes = [
['un palier de trois critères', abime((d) => { d.charge.reglages.priorites = trois; })],
['les animateurs au deuxième palier', abime((d) => { d.charge.reglages.priorites = animateursDAbord; })],
['à rebours, l’écart en dernier, sous « chacun »', abime((d) => {
d.charge.reglages.modeObjectifs = 'chacun';
d.charge.reglages.priorites = seuls;
})],
['l’ordre par défaut sous « l’équité d’abord » : absent', abime((d) => { d.charge.reglages.modeObjectifs = 'minimum'; })],
];
const echecs = variantes.flatMap(([libelle, texte]) => {
try {
analyser(texte);
return [];
} catch (erreur) {
return [`${libelle} : ${erreur.message}`];
}
});
assert.deepEqual(echecs, []);
});
test('l’ordre par défaut écrit, une liste vide ou une valeur qui n’est pas une liste sont des fautes de forme, nommées par leur chemin', () => {
assert.deepEqual(ecartsDeRefus([
['l’ordre par défaut écrit', abime((d) => { d.charge.reglages.priorites = prioritesParDefaut(); }), 'FORME', 'charge.reglages.priorites'],
['l’ordre par défaut écrit sous « chacun »', abime((d) => {
d.charge.reglages.modeObjectifs = 'chacun';
d.charge.reglages.priorites = prioritesParDefaut();
}), 'FORME', 'charge.reglages.priorites'],
['une liste vide', abime((d) => { d.charge.reglages.priorites = []; }), 'FORME', 'charge.reglages.priorites'],
['null', abime((d) => { d.charge.reglages.priorites = null; }), 'FORME', 'charge.reglages.priorites'],
['un objet', abime((d) => { d.charge.reglages.priorites = {}; }), 'FORME', 'charge.reglages.priorites'],
['un palier qui n’est pas une liste', abime((d) => {
d.charge.reglages.priorites = prioritesReglees();
d.charge.reglages.priorites[2] = { critere: 'collisionsCumulees', poids: 1 };
}), 'FORME', 'charge.reglages.priorites[2]'],
]), []);
});
test('chaque faute d’une liste se nomme par son chemin : l’entrée fautive, sinon le palier, sinon la liste', () => {
const avec = (modifier, mode) => abime((d) => {
const liste = prioritesReglees();
modifier(liste);
d.charge.reglages.priorites = liste;
if (mode !== undefined) d.charge.reglages.modeObjectifs = mode;
});
const P = 'charge.reglages.priorites';
assert.deepEqual(ecartsDeRefus([
['un palier vide', avec((l) => { l[2] = []; }), 'FORME', `${P}[2]`],
['un critère inconnu', avec((l) => { l[1][0].critere = 'tablesVoisines'; }), 'FORME', `${P}[1][0].critere`],
['un nom hérité d’Object', avec((l) => { l[1][0].critere = 'constructor'; }), 'FORME', `${P}[1][0].critere`],
['un critère en capitales', avec((l) => { l[0][0].critere = 'ECARTAUPLAFONDAPRIORIMAX'; }), 'FORME', `${P}[0][0].critere`],
['un poids nul', avec((l) => { l[1][1].poids = 0; }), 'FORME', `${P}[1][1].poids`],
['un poids de 101', avec((l) => { l[1][1].poids = 101; }), 'FORME', `${P}[1][1].poids`],
['un poids non entier', avec((l) => { l[1][1].poids = 2.5; }), 'FORME', `${P}[1][1].poids`],
['un poids en texte', avec((l) => { l[1][1].poids = '3'; }), 'FORME', `${P}[1][1].poids`],
['une entrée sans poids', avec((l) => { delete l[1][1].poids; }), 'FORME', `${P}[1][1].poids`],
['une clé inconnue dans une entrée', avec((l) => { l[1][0].note = 'urgent'; }), 'FORME', `${P}[1][0].note`],
['un critère répété', avec((l) => { l[3].push({ critere: 'excedentCollisions', poids: 1 }); }), 'FORME', `${P}[3]`],
['un critère absent', avec((l) => { l.splice(4, 1); }), 'FORME', P],
['l’écart qui partage son palier', avec((l) => { l[0].push(...l.splice(2, 1)[0]); }), 'FORME', `${P}[0]`],
['les rencontres répétées qui partagent leur palier', avec((l) => {
const [palier] = l.splice(4, 1);
l[3].push(...palier);
}), 'FORME', `${P}[3]`],
['les rencontres répétées au poids 2', avec((l) => { l[3][0].poids = 2; }), 'FORME', `${P}[3]`],
['les tours hors objectif partagés sous « chacun »', avec(() => {}, 'chacun'), 'FORME', `${P}[1]`],
['les tours hors objectif partagés sous « l’équité d’abord »', avec(() => {}, 'minimum'), 'FORME', `${P}[1]`],
]), []);
});
test('dans un fichier plus récent, un critère à venir lève FORMAT_PLUS_RECENT ; une clé à venir dans une entrée est ignorée', () => {
const lu = analyser(abime((d) => {
d.entete.format = FORMAT + 1;
d.charge.reglages.priorites = prioritesReglees();
d.charge.reglages.priorites[1][1].pente = 2;
}));
assert.deepEqual(lu.charge.reglages.priorites, prioritesReglees());
assert.deepEqual(ecartsDeRefus([
['un critère à venir', abime((d) => {
d.entete.format = FORMAT + 1;
d.charge.reglages.priorites = prioritesReglees();
d.charge.reglages.priorites[1][1].critere = 'tablesMelangees';
}), 'FORMAT_PLUS_RECENT', 'charge.reglages.priorites[1][1].critere', { format: FORMAT + 1 }],
]), []);
});
});
describe('priorites : le lecteur de l’ordre des priorités (§ 5.7, § 8.9)', () => {
test('absent, il rend l’ordre par défaut, une liste neuve à chaque appel', () => {
const { charge } = documentValide();
const lue = priorites(charge);
assert.deepEqual(lue, PRIORITES_PAR_DEFAUT);
lue[0][0].poids = 50;
lue.push([]);
assert.deepEqual(priorites(charge), PRIORITES_PAR_DEFAUT);
assert.deepEqual(priorites(creerCharge({ id: 'evt-neuf', nom: 'Atelier', siegesParDefaut: 8, tours: 3 })), PRIORITES_PAR_DEFAUT);
});
test('présent, il rend une copie de la liste portée, chaque palier rangé ; la charge reste intacte', () => {
const { charge } = documentValide();
charge.reglages.priorites = prioritesReglees();
charge.reglages.priorites[1].reverse();
geler(charge);
const lue = priorites(charge);
assert.deepEqual(lue, prioritesReglees());
lue[1][0].poids = 9;
assert.equal(charge.reglages.priorites[1][1].poids, 1);
});
test('une liste qui ne nomme pas un critère le reçoit à sa place de l’ordre par défaut, seul, au poids 1 (A16)', () => {
const { charge } = documentValide();
charge.reglages.priorites = prioritesReglees().filter(([{ critere }]) => critere !== 'animateursNonRencontres');
assert.deepEqual(priorites(charge), prioritesReglees());
});
});
describe('configurationDepuisCharge : l’ordre des priorités (contrat de données)', () => {
test('absent du fichier, il est absent de la configuration ; présent, la configuration en porte une copie rangée, le mode à côté', () => {
assert.ok(!Object.hasOwn(configurationDepuisCharge(documentValide().charge), 'priorites'));
const { charge } = documentValide();
charge.reglages.modeObjectifs = 'chacun';
charge.reglages.priorites = prioritesParDefaut().reverse();
geler(charge);
const configuration = configurationDepuisCharge(charge);
assert.deepEqual(configuration.priorites, prioritesParDefaut().reverse());
assert.equal(configuration.modeObjectifs, 'chacun');
configuration.priorites[0][0].poids = 4;
configuration.priorites.pop();
assert.equal(charge.reglages.priorites.length, CRITERES.length);
assert.equal(charge.reglages.priorites[0][0].poids, 1);
});
});

View file

@ -119,6 +119,17 @@
* servies (modeObjectifs)
* @property {true} [rencontrerAnimateurs] facultatif, présent seulement
* coché ; absent, décoché (critereCoche)
* @property {Array<Array<{critere: string, poids: number}>>} [priorites]
* facultatif, présent seulement hors de
* l'ordre par défaut : des paliers non
* vides, du premier au dernier, chaque
* critère du registre une fois, rangés
* dans un palier dans l'ordre du
* registre ; poids entiers de 1 à
* POIDS_MAX ; valide sous le mode des
* objectifs (verifierPriorites) ;
* absent, l'ordre par défaut
* (priorites)
*
* @typedef {Object} ProchainsIds entiers de 1 à 2^31 − 1, au-delà de tout
* identifiant attribué ; ils ne reculent

View file

@ -0,0 +1,5 @@
nom,prenom,etiquettes
Ombrelle,Iris,"finance, Montréal"
Pervenche,Théo,"bénévole; ressources humaines"
Lacasse,Ondine,
Sarcelle,Anouk,fínance
1 nom prenom etiquettes
2 Ombrelle Iris finance, Montréal
3 Pervenche Théo bénévole; ressources humaines
4 Lacasse Ondine
5 Sarcelle Anouk fínance