[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` | | `titre_pressenti` | `titre`, `rôle` |
| `exclu` | — | | `exclu` | — |
| `notes` | — | | `notes` | — |
| `etiquettes` | `étiquette`, `mots-clés` |
<!-- engendre:fin colonnes-csv --> <!-- engendre:fin colonnes-csv -->
@ -575,6 +576,7 @@ l’historique ; les noms des exemples sont inventés.
|---|---| |---|---|
| `ajouterParticipant` | Ajouté Iris Ombrelle | | `ajouterParticipant` | Ajouté Iris Ombrelle |
| `modifierParticipant` | Modifié Ondine Lacasse : courriel | | `modifierParticipant` | Modifié Ondine Lacasse : courriel |
| `changerEtiquettes` | Étiquettes de Iris Ombrelle : ajouté « finance » |
| `exclureParticipant` | Exclu Théo Pervenche | | `exclureParticipant` | Exclu Théo Pervenche |
| `reintegrerParticipant` | Réintégré Théo Pervenche | | `reintegrerParticipant` | Réintégré Théo Pervenche |
| `supprimerParticipant` | Supprimé Ondine Lacasse | | `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 | | `changerTours` | Nombre de tours : 3 ; 2 réservations retirées |
| `modifierReglage` | Séparer les appartenances : désactivé | | `modifierReglage` | Séparer les appartenances : désactivé |
| `changerCibleRedondance` | Cible de la somme de redondance : 1200 | | `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 | | `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 | | `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 | | `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 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;;;555-0101 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;;;;555-0102 Pommerel;Capucine;Société des Hérons;capucine.pommerel@exemple.test;;;;bénévole|ressources humaines;555-0102
Marbrelle;Gaspard;Cercle des Saules;;animation;;;555-0103 Marbrelle;Gaspard;Cercle des Saules;;animation;;;Finance;555-0103
Sablonnet;Ysaline;Chœur de l’Anse;;;;;555-0104 Sablonnet;Ysaline;Chœur de l’Anse;;;;;;555-0104
Givrecœur;Anselme;;anselme.givrecoeur@exemple.test;;;;555-0105 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 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 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 Coudraie;Apolline;Chœur de l’Anse;;;;"Arrive après le premier tour
repart avant le dessert";555-0108 repart avant le dessert";;555-0108
Lanterneau;Basile;Chœur de l’Anse;;;;;555-0109 Lanterneau;Basile;Chœur de l’Anse;;;;;;555-0109
Brunœil;;Cercle des Saules;brunoeil@exemple.test;;;;555-0110 Brunœil;;Cercle des Saules;brunoeil@exemple.test;;;;;555-0110
Ormelin;Faustine;Societe des herons;;;;;555-0111 Ormelin;Faustine;Societe des herons;;;;;;555-0111
;Clotilde;Cercle des Saules;clotilde@exemple.test;;;;555-0112 ;Clotilde;Cercle des Saules;clotilde@exemple.test;;;;;555-0112
Ardoisier;Edgar;Société des Hérons;;;;;555-0113 Ardoisier;Edgar;Société des Hérons;;;;;;555-0113
Cressonnier;Bertille;Cercle des Saules;;;;;555-0114 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 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,,,555-0101 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,,,,555-0102 Pommerel,Capucine,Société des Hérons,capucine.pommerel@exemple.test,,,,bénévole|ressources humaines,555-0102
Marbrelle,Gaspard,Cercle des Saules,,animation,,,555-0103 Marbrelle,Gaspard,Cercle des Saules,,animation,,,Finance,555-0103
Sablonnet,Ysaline,Chœur de l’Anse,,,,,555-0104 Sablonnet,Ysaline,Chœur de l’Anse,,,,,,555-0104
Givrecœur,Anselme,,anselme.givrecoeur@exemple.test,,,,555-0105 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 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 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 Coudraie,Apolline,Chœur de l’Anse,,,,"Arrive après le premier tour
repart avant le dessert",555-0108 repart avant le dessert",,555-0108
Lanterneau,Basile,Chœur de l’Anse,,,,,555-0109 Lanterneau,Basile,Chœur de l’Anse,,,,,,555-0109
Brunœil,,Cercle des Saules,brunoeil@exemple.test,,,,555-0110 Brunœil,,Cercle des Saules,brunoeil@exemple.test,,,,,555-0110
Ormelin,Faustine,Societe des herons,,,,,555-0111 Ormelin,Faustine,Societe des herons,,,,,,555-0111
,Clotilde,Cercle des Saules,clotilde@exemple.test,,,,555-0112 ,Clotilde,Cercle des Saules,clotilde@exemple.test,,,,,555-0112
Ardoisier,Edgar,Société des Hérons,,,,,555-0113 Ardoisier,Edgar,Société des Hérons,,,,,,555-0113
Cressonnier,Bertille,Cercle des Saules,,,,,555-0114 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 nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes;etiquettes
Mirvault;Isabelle;Comptoir Gravelune;;;non; Mirvault;Isabelle;Comptoir Gravelune;;;non;;
Neskière;Youssef;Verrerie Vaubrenne;;animation;non; Neskière;Youssef;Verrerie Vaubrenne;;animation;non;;
Abrevande;Félix;Imprimerie Yzorne;;;non; Abrevande;Félix;Imprimerie Yzorne;;;non;;
Fralvançon;Hugo;Brasserie Wassorel;;;non; Fralvançon;Hugo;Brasserie Wassorel;;;non;;
Fralveux;Manon;Menuiserie Estrolle;;;non; Fralveux;Manon;Menuiserie Estrolle;;;non;;
Luskotte;Catherine;Fonderie Estrolle;;;non; Luskotte;Catherine;Fonderie Estrolle;;;non;;
WAMMERWOLM-MAWWEMMEL;Zoé;Menuiserie Ulvardes;;;non; WAMMERWOLM-MAWWEMMEL;Zoé;Menuiserie Ulvardes;;;non;;
Neskichon;Louise;Ateliers Rossolde;;animation;non; Neskichon;Louise;Ateliers Rossolde;;animation;non;;
Urvesson;Catherine;Fabrique Ulvardes;;;non; Urvesson;Catherine;Fabrique Ulvardes;;;non;;
Hulvivet;Karim;Fonderie Xandrelle;;;non; Hulvivet;Karim;Fonderie Xandrelle;;;non;;
Selveron;Caroline;Ateliers Cassorgue;;;non; Selveron;Caroline;Ateliers Cassorgue;;;non;;
Korvault;Catherine;Tannerie Nervanche;;;non; Korvault;Catherine;Tannerie Nervanche;;;non;;
Fralvotte;Caroline;Fonderie Ivoranche;;;non; Fralvotte;Caroline;Fonderie Ivoranche;;;non;;
Jusvesson;Geneviève;Tannerie Belvarre;;;non; Jusvesson;Geneviève;Tannerie Belvarre;;;non;;
Abrevumet;Paul;Ateliers Aubrenc;;;non; Abrevumet;Paul;Ateliers Aubrenc;;;non;;
Fralvault;Léa;Fonderie Sorbagne;;;non; Fralvault;Léa;Fonderie Sorbagne;;;non;;
Obrivet;Fatima;Fabrique Sorbagne;;;non; Obrivet;Fatima;Fabrique Sorbagne;;;non;;
Voskeval;Philippe;Menuiserie Estrolle;;animation;non; Voskeval;Philippe;Menuiserie Estrolle;;animation;non;;
Dulveval;Zoé;Menuiserie Quimvaux;;;non; Dulveval;Zoé;Menuiserie Quimvaux;;;non;;
Korveux;Paul;Filature Olbrecq;;;non; Korveux;Paul;Filature Olbrecq;;;non;;
Luskière;Julie;Fabrique Sorbagne;;;non; Luskière;Julie;Fabrique Sorbagne;;;non;;
Clovande;Émilie;Ateliers Falvigne;;;non; Clovande;Émilie;Ateliers Falvigne;;;non;;
Hulvande;Paul;Verrerie Vaubrenne;;;non; Hulvande;Paul;Verrerie Vaubrenne;;;non;;
Fralvesson;Hugo;Filature Wassorel;;;non; Fralvesson;Hugo;Filature Wassorel;;;non;;
Urvière;Isabelle;Brasserie Belvarre;;;non; Urvière;Isabelle;Brasserie Belvarre;;;non;;
Rozvotte;Thomas;Verrerie Ivoranche;;;non; Rozvotte;Thomas;Verrerie Ivoranche;;;non;;
Bralmumet;Isabelle;Verrerie Aubrenc;;;non; Bralmumet;Isabelle;Verrerie Aubrenc;;;non;;
Dulvouze;Catherine;Ateliers Rossolde;;;non; Dulvouze;Catherine;Ateliers Rossolde;;;non;;
Voskichon;Marc;Menuiserie Estrolle;;;non; Voskichon;Marc;Menuiserie Estrolle;;;non;;
Mirvivet;Simon;Ateliers Ivoranche;;;non; Mirvivet;Simon;Ateliers Ivoranche;;;non;;
Clovault;Louise;Imprimerie Yzorne;;;non; Clovault;Louise;Imprimerie Yzorne;;;non;;
Voskeux;Lucie;Coopérative Morvalle;;;non; Voskeux;Lucie;Coopérative Morvalle;;;non;;
Eskignac;Amina;Brasserie Xandrelle;;;non; Eskignac;Amina;Brasserie Xandrelle;;;non;;
Korvouze;Fatima;Ateliers Tavrenne;;animation;non; Korvouze;Fatima;Ateliers Tavrenne;;animation;non;;
Selvoubre;Luc;Brasserie Sorbagne;;;non; Selvoubre;Luc;Brasserie Sorbagne;;;non;;
Fralvichon;Étienne;Papeterie Ulvardes;;;non; Fralvichon;Étienne;Papeterie Ulvardes;;;non;;
Voskivet;Philippe;Ateliers Tavrenne;;;non; Voskivet;Philippe;Ateliers Tavrenne;;;non;;
Quilmançon;Louise;Menuiserie Estrolle;;;non; Quilmançon;Louise;Menuiserie Estrolle;;;non;;
Luskande;Sophie;Fabrique Morvalle;;;non; Luskande;Sophie;Fabrique Morvalle;;;non;;
Abreveron;Pierre;Ateliers Rossolde;;;non; Abreveron;Pierre;Ateliers Rossolde;;;non;;
Neskeux;Philippe;Comptoir Xandrelle;;;non; Neskeux;Philippe;Comptoir Xandrelle;;;non;;
Neskançon;Amina;Fabrique Ulvardes;;;non; Neskançon;Amina;Fabrique Ulvardes;;;non;;
Tolvande;Léa;Ateliers Rossolde;;;non; Tolvande;Léa;Ateliers Rossolde;;;non;;
Quilmeron;Antoine;Fabrique Kalvenne;;;non; Quilmeron;Antoine;Fabrique Kalvenne;;;non;;
Clovumet;Guillaume;Verrerie Pravette;;;non; Clovumet;Guillaume;Verrerie Pravette;;;non;;
Eskeux;Michel;Papeterie Wassorel;;;non; Eskeux;Michel;Papeterie Wassorel;;;non;;
Hulvault;Louise;Coopérative Jolvane;;;non; Hulvault;Louise;Coopérative Jolvane;;;non;;
Hulveron;Catherine;Coopérative Rossolde;;animation;non; Hulveron;Catherine;Coopérative Rossolde;;animation;non;;
Ivrivet;François;Menuiserie Sorbagne;;animation;non; Ivrivet;François;Menuiserie Sorbagne;;animation;non;;
Eskumet;Samuel;Verrerie Ivoranche;;;non; Eskumet;Samuel;Verrerie Ivoranche;;;non;;
Fralvivet;Claire;Imprimerie Yzorne;;;non; Fralvivet;Claire;Imprimerie Yzorne;;;non;;
Mirveux;Léa;Menuiserie Wassorel;;;non; Mirveux;Léa;Menuiserie Wassorel;;;non;;
Gorzesson;Karim;Papeterie Ulvardes;;;non; Gorzesson;Karim;Papeterie Ulvardes;;;non;;
Fralvumet;Jean;Ateliers Falvigne;;animation;non; Fralvumet;Jean;Ateliers Falvigne;;animation;non;;
Urvançon;Manon;Ateliers Tavrenne;;animation;non; Urvançon;Manon;Ateliers Tavrenne;;animation;non;;
Quilmande;Sylvie;Imprimerie Yzorne;;;non; Quilmande;Sylvie;Imprimerie Yzorne;;;non;;
Gorzichon;François;Ateliers Estrolle;;;non; Gorzichon;François;Ateliers Estrolle;;;non;;
Pruvesson;Amina;Fonderie Sorbagne;;;non; Pruvesson;Amina;Fonderie Sorbagne;;;non;;
Quilmière;Lucie;Fabrique Ivoranche;;;non; Quilmière;Lucie;Fabrique Ivoranche;;;non;;
Korvesson;Fatima;Ateliers Rossolde;;;non; Korvesson;Fatima;Ateliers Rossolde;;;non;;
Selvesson;Nicolas;Imprimerie Yzorne;;;non; Selvesson;Nicolas;Imprimerie Yzorne;;;non;;
Selvivet;Louis;Menuiserie Hostrenne;;animation;non; Selvivet;Louis;Menuiserie Hostrenne;;animation;non;;
Neskesson;Louise;Tannerie Belvarre;;;non; Neskesson;Louise;Tannerie Belvarre;;;non;;
Tolvesson;Lucie;Filature Zelvaine;;;non; Tolvesson;Lucie;Filature Zelvaine;;;non;;
Quilmesson;Olivier;Comptoir Gravelune;;;non; Quilmesson;Olivier;Comptoir Gravelune;;;non;;
Luskivet;Guillaume;Brasserie Lavorneau;;;non; Luskivet;Guillaume;Brasserie Lavorneau;;;non;;
Neskault;Gabriel;Menuiserie Tavrenne;;;non; Neskault;Gabriel;Menuiserie Tavrenne;;;non;;
Urvumet;Antoine;Menuiserie Hostrenne;;;non; Urvumet;Antoine;Menuiserie Hostrenne;;;non;;
Jusvançon;Olivier;Brasserie Morvalle;;;non; Jusvançon;Olivier;Brasserie Morvalle;;;non;;
Dulvançon;Simon;Menuiserie Ivoranche;;;non; Dulvançon;Simon;Menuiserie Ivoranche;;;non;;
Gorzard;Chloé;Fabrique Sorbagne;;;non; Gorzard;Chloé;Fabrique Sorbagne;;;non;;
Pruveux;Gabriel;Papeterie Estrolle;;;non; Pruveux;Gabriel;Papeterie Estrolle;;;non;;
Clovière;Manon;Ateliers Ivoranche;;;non; Clovière;Manon;Ateliers Ivoranche;;;non;;
Obrouze;François;Tannerie Olbrecq;;animation;non; Obrouze;François;Tannerie Olbrecq;;animation;non;;
Tolveval;Karim;Tannerie Belvarre;;;non; Tolveval;Karim;Tannerie Belvarre;;;non;;
Dulvumet;Geneviève;Menuiserie Quimvaux;;animation;non; Dulvumet;Geneviève;Menuiserie Quimvaux;;animation;non;;
Bérêçàuvïlle-Dœûmôntëÿ;Hugo;Ateliers Falvigne;;;non; Bérêçàuvïlle-Dœûmôntëÿ;Hugo;Ateliers Falvigne;;;non;;
Gorzeron;Claire;Menuiserie Hostrenne;;;non; Gorzeron;Claire;Menuiserie Hostrenne;;;non;;
Luskumet;Hélène;Brasserie Xandrelle;;;non; Luskumet;Hélène;Brasserie Xandrelle;;;non;;
Fralvande;Louis;Ateliers Rossolde;;;non; Fralvande;Louis;Ateliers Rossolde;;;non;;
Selvault;Émilie;Fabrique Sorbagne;;;non; Selvault;Émilie;Fabrique Sorbagne;;;non;;
Neskumet;Léa;Verrerie Aubrenc;;;non; Neskumet;Léa;Verrerie Aubrenc;;;non;;
Korvichon;Isabelle;Menuiserie Ulvardes;;;non; Korvichon;Isabelle;Menuiserie Ulvardes;;;non;;
Obrande;Michel;Menuiserie Estrolle;;;non; Obrande;Michel;Menuiserie Estrolle;;;non;;
Luskeval;Isabelle;Brasserie Wassorel;;;non; Luskeval;Isabelle;Brasserie Wassorel;;;non;;
Tolvoubre;Fatima;Papeterie Ulvardes;;;non; Tolvoubre;Fatima;Papeterie Ulvardes;;;non;;
Gorzière;Zoé;Menuiserie Olbrecq;;;non; Gorzière;Zoé;Menuiserie Olbrecq;;;non;;
Korvignac;Sylvie;Fabrique Ulvardes;;;non; Korvignac;Sylvie;Fabrique Ulvardes;;;non;;
Obreval;Antoine;Ateliers Cassorgue;;;non; Obreval;Antoine;Ateliers Cassorgue;;;non;;
Korveron;Michel;Fonderie Sorbagne;;animation;non; Korveron;Michel;Fonderie Sorbagne;;animation;non;;
Tolvumet;Camille;Fonderie Estrolle;;;non; Tolvumet;Camille;Fonderie Estrolle;;;non;;
Dulvesson;Marie;Ateliers Cassorgue;;;non; Dulvesson;Marie;Ateliers Cassorgue;;;non;;
Jusvard;Youssef;Tannerie Aubrenc;;animation;non; Jusvard;Youssef;Tannerie Aubrenc;;animation;non;;
Tolvignac;Guillaume;Menuiserie Sorbagne;;;non; Tolvignac;Guillaume;Menuiserie Sorbagne;;;non;;
Mirvotte;Gabriel;Tannerie Belvarre;;;non; Mirvotte;Gabriel;Tannerie Belvarre;;;non;;
Urvard;Marie;Filature Wassorel;;animation;non; Urvard;Marie;Filature Wassorel;;animation;non;;
Bralmesson;Marie;Coopérative Dravenne;;animation;non; Bralmesson;Marie;Coopérative Dravenne;;animation;non;;
Neskignac;Paul;Brasserie Sorbagne;;;non; Neskignac;Paul;Brasserie Sorbagne;;;non;;
Bralmeux;Samuel;Ateliers Rossolde;;animation;non; Bralmeux;Samuel;Ateliers Rossolde;;animation;non;;
Ivrotte;Raphaël;Coopérative Rossolde;;;non; Ivrotte;Raphaël;Coopérative Rossolde;;;non;;
Luskouze;Marc;Ateliers Rossolde;;;non; Luskouze;Marc;Ateliers Rossolde;;;non;;
Jusvotte;Sylvie;Brasserie Belvarre;;;non; Jusvotte;Sylvie;Brasserie Belvarre;;;non;;
Bralmoubre;Véronique;Fonderie Xandrelle;;;non; Bralmoubre;Véronique;Fonderie Xandrelle;;;non;;
Selvard;Pierre;Verrerie Vaubrenne;;;non; Selvard;Pierre;Verrerie Vaubrenne;;;non;;
Hulveux;Marie;Coopérative Rossolde;;;non; Hulveux;Marie;Coopérative Rossolde;;;non;;
Pruvotte;Sophie;Imprimerie Yzorne;;animation;non; Pruvotte;Sophie;Imprimerie Yzorne;;animation;non;;
Fralveval;Karim;Coopérative Gravelune;;;non; Fralveval;Karim;Coopérative Gravelune;;;non;;
Gorzignac;Philippe;Imprimerie Falvigne;;;non; Gorzignac;Philippe;Imprimerie Falvigne;;;non;;
Selvouze;Étienne;Ateliers Tavrenne;;;non; Selvouze;Étienne;Ateliers Tavrenne;;;non;;
Hulvichon;Louis;Ateliers Falvigne;;;non; Hulvichon;Louis;Ateliers Falvigne;;;non;;
Urvoubre;Sylvie;Verrerie Vaubrenne;;;non; Urvoubre;Sylvie;Verrerie Vaubrenne;;;non;;
Pruveval;Pierre;Comptoir Xandrelle;;;non; Pruveval;Pierre;Comptoir Xandrelle;;;non;;
Hulvotte;Guillaume;Verrerie Vaubrenne;;;non; Hulvotte;Guillaume;Verrerie Vaubrenne;;;non;;
Abrevignac;Geneviève;Brasserie Morvalle;;;non; Abrevignac;Geneviève;Brasserie Morvalle;;;non;;
Abrevault;Gabriel;Ateliers Rossolde;;animation;non; Abrevault;Gabriel;Ateliers Rossolde;;animation;non;;
Bralmignac;Amina;Fonderie Estrolle;;;non; Bralmignac;Amina;Fonderie Estrolle;;;non;;
Bralmançon;Olivier;Comptoir Lavorneau;;;non; Bralmançon;Olivier;Comptoir Lavorneau;;;non;;
Luskoubre;Marc;Verrerie Vaubrenne;;;non; Luskoubre;Marc;Verrerie Vaubrenne;;;non;;
Abrevoubre;Geneviève;Menuiserie Gravelune;;;non; Abrevoubre;Geneviève;Menuiserie Gravelune;;;non;;
Korvoubre;Raphaël;Verrerie Vaubrenne;;;non; Korvoubre;Raphaël;Verrerie Vaubrenne;;;non;;
Mirvard;Pierre;Verrerie Ivoranche;;;non; Mirvard;Pierre;Verrerie Ivoranche;;;non;;
Jusvumet;Camille;Fonderie Estrolle;;;non; Jusvumet;Camille;Fonderie Estrolle;;;non;;
Cloveval;Samuel;Menuiserie Ulvardes;;;non; Cloveval;Samuel;Menuiserie Ulvardes;;;non;;
Selvière;Antoine;Coopérative Dravenne;;;non; Selvière;Antoine;Coopérative Dravenne;;;non;;
Jusvouze;Véronique;Verrerie Vaubrenne;;;non; Jusvouze;Véronique;Verrerie Vaubrenne;;;non;;
Neskivet;Paul;Comptoir Jolvane;;;non; Neskivet;Paul;Comptoir Jolvane;;;non;;
Dulvande;Samuel;Verrerie Pravette;;;non; Dulvande;Samuel;Verrerie Pravette;;;non;;
Bralmault;Antoine;Fabrique Sorbagne;;animation;non; Bralmault;Antoine;Fabrique Sorbagne;;animation;non;;
Abrevière;Émilie;Imprimerie Yzorne;;animation;non; Abrevière;Émilie;Imprimerie Yzorne;;animation;non;;
Selveval;Lucie;Filature Zelvaine;;;non; Selveval;Lucie;Filature Zelvaine;;;non;;
Tolvichon;Olivier;Fonderie Xandrelle;;;non; Tolvichon;Olivier;Fonderie Xandrelle;;;non;;
Hulvard;Fatima;Ateliers Estrolle;;;non; Hulvard;Fatima;Ateliers Estrolle;;;non;;
Ivreux;Alexandre;Tannerie Aubrenc;;;non; Ivreux;Alexandre;Tannerie Aubrenc;;;non;;
Korveval;Félix;Tannerie Nervanche;;;non; Korveval;Félix;Tannerie Nervanche;;;non;;
Urvichon;Karim;Tannerie Nervanche;;;non; Urvichon;Karim;Tannerie Nervanche;;;non;;
Dulvichon;Émilie;Menuiserie Ulvardes;;;non; Dulvichon;Émilie;Menuiserie Ulvardes;;;non;;
Tolvouze;Luc;Fabrique Ivoranche;;;non; Tolvouze;Luc;Fabrique Ivoranche;;;non;;
Jusvivet;Claire;Imprimerie Yzorne;;animation;non; Jusvivet;Claire;Imprimerie Yzorne;;animation;non;;
Quilmault;Amina;Coopérative Jolvane;;;non; Quilmault;Amina;Coopérative Jolvane;;;non;;
Ivrouze;François;Fonderie Aubrenc;;;non; Ivrouze;François;Fonderie Aubrenc;;;non;;
Gorzumet;Hélène;Fonderie Aubrenc;;;non; Gorzumet;Hélène;Fonderie Aubrenc;;;non;;
Ivrault;Sophie;Coopérative Morvalle;;;non; Ivrault;Sophie;Coopérative Morvalle;;;non;;
Bralmière;Louise;Tannerie Quimvaux;;;non; Bralmière;Louise;Tannerie Quimvaux;;;non;;
Korvande;Nicolas;Comptoir Sorbagne;;;non; Korvande;Nicolas;Comptoir Sorbagne;;;non;;
Ivrumet;Geneviève;Coopérative Jolvane;;;non; Ivrumet;Geneviève;Coopérative Jolvane;;;non;;
Obresson;Geneviève;Coopérative Gravelune;;;non; Obresson;Geneviève;Coopérative Gravelune;;;non;;
Obrançon;Guillaume;Papeterie Hostrenne;;;non; Obrançon;Guillaume;Papeterie Hostrenne;;;non;;
Urvignac;Fatima;Ateliers Aubrenc;;;non; Urvignac;Fatima;Ateliers Aubrenc;;;non;;
Bralmotte;Claire;Ateliers Tavrenne;;;non; Bralmotte;Claire;Ateliers Tavrenne;;;non;;
Hulvignac;Félix;Imprimerie Hostrenne;;;non; Hulvignac;Félix;Imprimerie Hostrenne;;;non;;
Clovotte;François;Coopérative Rossolde;;;non; Clovotte;François;Coopérative Rossolde;;;non;;
Dulvoubre;Philippe;Brasserie Belvarre;;;non; Dulvoubre;Philippe;Brasserie Belvarre;;;non;;
Rozveron;Samuel;Fonderie Sorbagne;;;non; Rozveron;Samuel;Fonderie Sorbagne;;;non;;
Hulvoubre;Pierre;Menuiserie Olbrecq;;;non; Hulvoubre;Pierre;Menuiserie Olbrecq;;;non;;
Urvouze;Émilie;Comptoir Gravelune;;;non; Urvouze;Émilie;Comptoir Gravelune;;;non;;
Korvançon;Félix;Fonderie Ivoranche;;;non; Korvançon;Félix;Fonderie Ivoranche;;;non;;
Neskande;Émilie;Coopérative Gravelune;;;non; Neskande;Émilie;Coopérative Gravelune;;;non;;
Mirvumet;Chantal;Menuiserie Gravelune;;;non; Mirvumet;Chantal;Menuiserie Gravelune;;;non;;
Korvotte;Alexandre;Menuiserie Nervanche;;;non; Korvotte;Alexandre;Menuiserie Nervanche;;;non;;
Eskeval;Zoé;Tannerie Nervanche;;;non; Eskeval;Zoé;Tannerie Nervanche;;;non;;
Neskard;Camille;Menuiserie Gravelune;;animation;non; Neskard;Camille;Menuiserie Gravelune;;animation;non;;
Tolveron;Manon;Brasserie Belvarre;;animation;non; Tolveron;Manon;Brasserie Belvarre;;animation;non;;
Voskoubre;Nathalie;Fonderie Kalvenne;;;non; Voskoubre;Nathalie;Fonderie Kalvenne;;;non;;
Pruvière;Sophie;Ateliers Rossolde;;;non; Pruvière;Sophie;Ateliers Rossolde;;;non;;
Obrichon;Paul;Ateliers Rossolde;;;non; Obrichon;Paul;Ateliers Rossolde;;;non;;
Abrevançon;Fatima;Tannerie Nervanche;;animation;non; Abrevançon;Fatima;Tannerie Nervanche;;animation;non;;
Eskière;Nathalie;Fonderie Xandrelle;;;non; Eskière;Nathalie;Fonderie Xandrelle;;;non;;
Ivreron;Caroline;Imprimerie Falvigne;;;non; Ivreron;Caroline;Imprimerie Falvigne;;;non;;
Neskoubre;Véronique;Fonderie Estrolle;;;non; Neskoubre;Véronique;Fonderie Estrolle;;;non;;
Urvivet;Anne;Fonderie Kalvenne;;;non; Urvivet;Anne;Fonderie Kalvenne;;;non;;
Hulvançon;Olivier;Menuiserie Estrolle;;;non; Hulvançon;Olivier;Menuiserie Estrolle;;;non;;
Clovançon;Karim;Fabrique Sorbagne;;;non; Clovançon;Karim;Fabrique Sorbagne;;;non;;
Quilmotte;Zoé;Ateliers Aubrenc;;;non; Quilmotte;Zoé;Ateliers Aubrenc;;;non;;
Eskouze;Félix;Filature Zelvaine;;;non; Eskouze;Félix;Filature Zelvaine;;;non;;
Rozvichon;Amina;Verrerie Olbrecq;;;non; Rozvichon;Amina;Verrerie Olbrecq;;;non;;
Fralvière;Simon;Comptoir Sorbagne;;;non; Fralvière;Simon;Comptoir Sorbagne;;;non;;
Vandergrolcquesmirtavelle-Ostrovaubelindigues;Marc;Tannerie Aubrenc;;animation;non; Vandergrolcquesmirtavelle-Ostrovaubelindigues;Marc;Tannerie Aubrenc;;animation;non;;
Hulvière;Pierre;Papeterie Hostrenne;;;non; Hulvière;Pierre;Papeterie Hostrenne;;;non;;
Jusvault;Raphaël;Fabrique Sorbagne;;;non; Jusvault;Raphaël;Fabrique Sorbagne;;;non;;
Dulvivet;Guillaume;Filature Olbrecq;;animation;non; Dulvivet;Guillaume;Filature Olbrecq;;animation;non;;
Fralvoubre;Pierre;Imprimerie Falvigne;;;non; Fralvoubre;Pierre;Imprimerie Falvigne;;;non;;
Selveux;Hélène;Brasserie Lavorneau;;;non; Selveux;Hélène;Brasserie Lavorneau;;;non;;
Rozvançon;Caroline;Brasserie Belvarre;;;non; Rozvançon;Caroline;Brasserie Belvarre;;;non;;
Korvière;Olivier;Ateliers Falvigne;;;non; Korvière;Olivier;Ateliers Falvigne;;;non;;
Ivrichon;Nicolas;Fabrique Sorbagne;;;non; Ivrichon;Nicolas;Fabrique Sorbagne;;;non;;
Korvumet;Félix;Imprimerie Yzorne;;;non; Korvumet;Félix;Imprimerie Yzorne;;;non;;
Voskande;Félix;Ateliers Falvigne;;animation;non; Voskande;Félix;Ateliers Falvigne;;animation;non;;
Selvumet;Guillaume;Papeterie Estrolle;;;non; Selvumet;Guillaume;Papeterie Estrolle;;;non;;
Urvotte;Michel;Verrerie Ivoranche;;;non; Urvotte;Michel;Verrerie Ivoranche;;;non;;
Eskault;Émilie;Coopérative Jolvane;;;non; Eskault;Émilie;Coopérative Jolvane;;;non;;
Bralmeval;Hélène;Brasserie Dravenne;;;non; Bralmeval;Hélène;Brasserie Dravenne;;;non;;
Hulvesson;Guillaume;Comptoir Jolvane;;;non; Hulvesson;Guillaume;Comptoir Jolvane;;;non;;
Jusvoubre;Caroline;Fabrique Sorbagne;;;non; Jusvoubre;Caroline;Fabrique Sorbagne;;;non;;
Luskeux;Félix;Tannerie Belvarre;;;non; Luskeux;Félix;Tannerie Belvarre;;;non;;
Pruvouze;Antoine;Imprimerie Yzorne;;;non; Pruvouze;Antoine;Imprimerie Yzorne;;;non;;
Quilmignac;Julie;Menuiserie Jolvane;;;non; Quilmignac;Julie;Menuiserie Jolvane;;;non;;
Tolvançon;Étienne;Menuiserie Estrolle;;animation;non; Tolvançon;Étienne;Menuiserie Estrolle;;animation;non;;
Voskumet;Hugo;Ateliers Tavrenne;;;non; Voskumet;Hugo;Ateliers Tavrenne;;;non;;
Quilmumet;Manon;Fonderie Sorbagne;;;non; Quilmumet;Manon;Fonderie Sorbagne;;;non;;
Voskouze;Marc;Fonderie Estrolle;;;non; Voskouze;Marc;Fonderie Estrolle;;;non;;
Abrevard;Samuel;Filature Dravenne;;;non; Abrevard;Samuel;Filature Dravenne;;;non;;
Luskichon;Guillaume;Brasserie Belvarre;;animation;non; Luskichon;Guillaume;Brasserie Belvarre;;animation;non;;
Clovesson;Manon;Fabrique Sorbagne;;;non; Clovesson;Manon;Fabrique Sorbagne;;;non;;
Hulvumet;Simon;Ateliers Ivoranche;;;non; Hulvumet;Simon;Ateliers Ivoranche;;;non;;
Voskeron;Lucie;Verrerie Ivoranche;;;non; Voskeron;Lucie;Verrerie Ivoranche;;;non;;
Selvignac;Thomas;Brasserie Sorbagne;;animation;non; Selvignac;Thomas;Brasserie Sorbagne;;animation;non;;
Neskeron;Jean;Filature Zelvaine;;;non; Neskeron;Jean;Filature Zelvaine;;;non;;
Pruvivet;Manon;Menuiserie Aubrenc;;;non; Pruvivet;Manon;Menuiserie Aubrenc;;;non;;
Clovard;Sophie;Brasserie Belvarre;;;non; Clovard;Sophie;Brasserie Belvarre;;;non;;
Gorzeval;Étienne;Papeterie Ulvardes;;;non; Gorzeval;Étienne;Papeterie Ulvardes;;;non;;
Ivroubre;Philippe;Ateliers Tavrenne;;animation;non; Ivroubre;Philippe;Ateliers Tavrenne;;animation;non;;
Luskault;Gabriel;Verrerie Gravelune;;;non; Luskault;Gabriel;Verrerie Gravelune;;;non;;
Korvard;Émilie;Verrerie Vaubrenne;;;non; Korvard;Émilie;Verrerie Vaubrenne;;;non;;
Quilmichon;Thomas;Fonderie Estrolle;;;non; Quilmichon;Thomas;Fonderie Estrolle;;;non;;
Eskançon;Alexandre;Menuiserie Hostrenne;;;non; Eskançon;Alexandre;Menuiserie Hostrenne;;;non;;
Luskançon;Émilie;Ateliers Tavrenne;;;non; Luskançon;Émilie;Ateliers Tavrenne;;;non;;
Dulvault;Marc;Verrerie Ivoranche;;;non; Dulvault;Marc;Verrerie Ivoranche;;;non;;
Rozveval;Hélène;Comptoir Ivoranche;;;non; Rozveval;Hélène;Comptoir Ivoranche;;;non;;
Pruvançon;Julie;Imprimerie Yzorne;;;non; Pruvançon;Julie;Imprimerie Yzorne;;;non;;
Mirveron;Louis;Filature Wassorel;;;non; Mirveron;Louis;Filature Wassorel;;;non;;
Eskard;François;Fonderie Xandrelle;;;non; Eskard;François;Fonderie Xandrelle;;;non;;
Bralmouze;Hugo;Tannerie Falvigne;;animation;non; Bralmouze;Hugo;Tannerie Falvigne;;animation;non;;
Ivrançon;Louis;Coopérative Morvalle;;;non; Ivrançon;Louis;Coopérative Morvalle;;;non;;
Clovivet;Karim;Fonderie Kalvenne;;;non; Clovivet;Karim;Fonderie Kalvenne;;;non;;
Rozvande;Léa;Verrerie Olbrecq;;;non; Rozvande;Léa;Verrerie Olbrecq;;;non;;
Hulvouze;Amina;Coopérative Dravenne;;;non; Hulvouze;Amina;Coopérative Dravenne;;;non;;
Eskesson;Étienne;Menuiserie Ulvardes;;;non; Eskesson;Étienne;Menuiserie Ulvardes;;;non;;
Abreveval;Philippe;Ateliers Falvigne;;;non; Abreveval;Philippe;Ateliers Falvigne;;;non;;
Quilmeux;Émilie;Ateliers Falvigne;;;non; Quilmeux;Émilie;Ateliers Falvigne;;;non;;
Ivreval;Gabriel;Verrerie Vaubrenne;;animation;non; Ivreval;Gabriel;Verrerie Vaubrenne;;animation;non;;
Voskotte;Simon;Fabrique Ivoranche;;;non; Voskotte;Simon;Fabrique Ivoranche;;;non;;
Obrignac;Chantal;Imprimerie Yzorne;;;non; Obrignac;Chantal;Imprimerie Yzorne;;;non;;
Eskotte;Guillaume;Verrerie Nervanche;;;non; Eskotte;Guillaume;Verrerie Nervanche;;;non;;
Obrard;Léa;Comptoir Sorbagne;;;non; Obrard;Léa;Comptoir Sorbagne;;;non;;
Gorzivet;Guillaume;Fonderie Estrolle;;;non; Gorzivet;Guillaume;Fonderie Estrolle;;;non;;
Mirvançon;Geneviève;Brasserie Belvarre;;;non; Mirvançon;Geneviève;Brasserie Belvarre;;;non;;
Selvotte;Julie;Comptoir Xandrelle;;;non; Selvotte;Julie;Comptoir Xandrelle;;;non;;
Mirvesson;Amina;Brasserie Sorbagne;;;non; Mirvesson;Amina;Brasserie Sorbagne;;;non;;
Luskignac;Isabelle;Papeterie Estrolle;;;non; Luskignac;Isabelle;Papeterie Estrolle;;;non;;
Obrumet;Chloé;Comptoir Yzorne;;;non; Obrumet;Chloé;Comptoir Yzorne;;;non;;
Abrevesson;Anne;Fonderie Estrolle;;;non; Abrevesson;Anne;Fonderie Estrolle;;;non;;
Rozvumet;Fatima;Menuiserie Hostrenne;;;non; Rozvumet;Fatima;Menuiserie Hostrenne;;;non;;
Mirvoubre;Guillaume;Comptoir Sorbagne;;;non; Mirvoubre;Guillaume;Comptoir Sorbagne;;;non;;
Rozvoubre;Véronique;Fonderie Xandrelle;;;non; Rozvoubre;Véronique;Fonderie Xandrelle;;;non;;
Gorzotte;Claire;Menuiserie Olbrecq;;;non; Gorzotte;Claire;Menuiserie Olbrecq;;;non;;
Ivrière;Antoine;Fabrique Sorbagne;;;non; Ivrière;Antoine;Fabrique Sorbagne;;;non;;
Selvichon;Isabelle;Tannerie Nervanche;;;non; Selvichon;Isabelle;Tannerie Nervanche;;;non;;
Urvault;Isabelle;Comptoir Yzorne;;;non; Urvault;Isabelle;Comptoir Yzorne;;;non;;
Voskesson;Julie;Brasserie Belvarre;;;non; Voskesson;Julie;Brasserie Belvarre;;;non;;
Abrevotte;Chantal;Ateliers Ivoranche;;;non; Abrevotte;Chantal;Ateliers Ivoranche;;;non;;
Neskotte;Amina;Fabrique Kalvenne;;;non; Neskotte;Amina;Fabrique Kalvenne;;;non;;
Voskançon;Étienne;Menuiserie Olbrecq;;;non; Voskançon;Étienne;Menuiserie Olbrecq;;;non;;
Fralveron;Zoé;Menuiserie Sorbagne;;;non; Fralveron;Zoé;Menuiserie Sorbagne;;;non;;
Rozvouze;Marc;Ateliers Estrolle;;;non; Rozvouze;Marc;Ateliers Estrolle;;;non;;
Abreveux;Sylvie;Comptoir Jolvane;;;non; Abreveux;Sylvie;Comptoir Jolvane;;;non;;
Pruvignac;Karim;Menuiserie Estrolle;;;non; Pruvignac;Karim;Menuiserie Estrolle;;;non;;
Mirveval;Zoé;Fabrique Kalvenne;;;non; Mirveval;Zoé;Fabrique Kalvenne;;;non;;
Fralvignac;Philippe;Menuiserie Estrolle;;;non; Fralvignac;Philippe;Menuiserie Estrolle;;;non;;
Gorzeux;Gabriel;Menuiserie Ulvardes;;;non; Gorzeux;Gabriel;Menuiserie Ulvardes;;;non;;
Urveval;Jean;Menuiserie Quimvaux;;;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 nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes;etiquettes
Bralmumet;Caroline;Ateliers Jolvane;;;non; Bralmumet;Caroline;Ateliers Jolvane;;;non;;
Neskault;Hugo;Tannerie Cassorgue;;;non; Neskault;Hugo;Tannerie Cassorgue;;;non;;
Ivrotte;Youssef;Verrerie Ulvardes;;;non; Ivrotte;Youssef;Verrerie Ulvardes;;;non;;
Selvière;Louis;Tannerie Cassorgue;;;non; Selvière;Louis;Tannerie Cassorgue;;;non;;
Selvançon;Youssef;Ateliers Jolvane;;;non; Selvançon;Youssef;Ateliers Jolvane;;;non;;
Quilmoubre;Amina;Verrerie Ulvardes;;;non; Quilmoubre;Amina;Verrerie Ulvardes;;;non;;
Voskotte;Julie;Verrerie Ulvardes;;;non; Voskotte;Julie;Verrerie Ulvardes;;;non;;
Neskivet;Paul;Ateliers Jolvane;;;non; Neskivet;Paul;Ateliers Jolvane;;;non;;
Bralmignac;Michel;Tannerie Cassorgue;;;non; Bralmignac;Michel;Tannerie Cassorgue;;;non;;
Tolveval;Zoé;Verrerie Ulvardes;;;non; Tolveval;Zoé;Verrerie Ulvardes;;;non;;
Urvard;Luc;Tannerie Cassorgue;;;non; Urvard;Luc;Tannerie Cassorgue;;;non;;
Mirveval;Véronique;Ateliers Jolvane;;;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. // la sortie ne porte que des durées.
import { beforeAll, describe, expect, test } from 'vitest'; import { beforeAll, describe, expect, test } from 'vitest';
import { commands } from 'vitest/browser'; import { commands } from 'vitest/browser';
import { configurationDe } from '../../src/application/configuration.js';
import { creerExecuteurTravailleur } from '../../src/application/executeur_travailleur.js'; import { creerExecuteurTravailleur } from '../../src/application/executeur_travailleur.js';
import { coeursDe, ralentissement } from '../../src/application/generation.js'; import { coeursDe, ralentissement } from '../../src/application/generation.js';
import { texteLivre } from '../../src/demo/livrees.js'; import { texteLivre } from '../../src/demo/livrees.js';
import Travailleur from '../../src/interface/generation/travailleur.js?worker&inline'; 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 { controlerCadre, ecrireSortie, ralentirCpu } from './navigateur.js';
import { verifierReleve } from './releve.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. // Les rondes de mesure, et la ronde de plus d'une mesure bruitée.
const REPETITIONS = 3; 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. // Les processeurs logiques du poste, tels que l'exécuteur de production les lit.
const L = coeursDe(navigator.hardwareConcurrency); const L = coeursDe(navigator.hardwareConcurrency);

View file

@ -16,11 +16,12 @@
import { realpathSync } from 'node:fs'; import { realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { configurationPessimiste } from '../../test/fixtures/banc/pessimiste.js'; import { configurationPessimiste } from '../../test/fixtures/banc/pessimiste.js';
import { configurationDe } from '../../src/application/configuration.js';
import { LIVREES } from '../../src/demo/livrees.js'; import { LIVREES } from '../../src/demo/livrees.js';
import { normaliser } from '../../src/moteur/configuration.js'; import { normaliser } from '../../src/moteur/configuration.js';
import { diagnostiquer } from '../../src/moteur/diagnostic.js'; import { diagnostiquer } from '../../src/moteur/diagnostic.js';
import { plafondsAPriori } from '../../src/moteur/plafond.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. // Les démonstrations livrées que le § 19.10 nomme : la grande et sa variante.
const DEMONSTRATIONS_MESUREES = ['grande', 'grande-sans-exception']; 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 * Les configurations du banc, dans l'ordre du relevé : la grande
* démonstration et sa variante, lues dans leur fichier livré (src/demo/ * 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. * (test/fixtures/banc/pessimiste.js). Des objets neufs à chaque appel.
* *
* @returns {Array<{cle: string, configuration: import('../../src/moteur/types.js').Configuration}>} * @returns {Array<{cle: string, configuration: import('../../src/moteur/types.js').Configuration}>}
@ -124,7 +126,7 @@ export function mesurerPlafondAPriori({ configurations, maintenant, repetitions
export function configurationsDuBanc() { export function configurationsDuBanc() {
const livrees = DEMONSTRATIONS_MESUREES.map((cle) => ({ const livrees = DEMONSTRATIONS_MESUREES.map((cle) => ({
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() }]; 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 { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { COMMANDES } from '../src/application/commandes.js'; import { COMMANDES } from '../src/application/commandes.js';
import { configurationDe } from '../src/application/configuration.js';
import { creerFormat } from '../src/application/format.js'; import { creerFormat } from '../src/application/format.js';
import { TABLES_APPLICATION } from '../src/application/libelles.js'; import { TABLES_APPLICATION } from '../src/application/libelles.js';
import { creerTraduction } from '../src/application/traduction.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 { TABLES_INTERFACE } from '../src/interface/libelles.js';
import { RACCOURCIS } from '../src/interface/raccourcis.js'; import { RACCOURCIS } from '../src/interface/raccourcis.js';
import { diagnostiquer } from '../src/moteur/diagnostic.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 { VERSION } from '../src/version.genere.js';
import { CHEMIN_MANIFESTE, cheminImage, ecartDeVersion, fautesDImage } from './documentation/manifeste.js'; import { CHEMIN_MANIFESTE, cheminImage, ecartDeVersion, fautesDImage } from './documentation/manifeste.js';
import { tableauConstantes } from './banc/releve.js'; import { tableauConstantes } from './banc/releve.js';
@ -362,6 +363,10 @@ export const EXEMPLES_COMMANDES = Object.freeze({
changerTours: { tours: 3, retirees: 2 }, changerTours: { tours: 3, retirees: 2 },
modifierReglage: { cle: 'separerAppartenances', valeur: false }, modifierReglage: { cle: 'separerAppartenances', valeur: false },
changerCibleRedondance: { cible: 1200 }, 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 }, reserver: { personne: 'Théo Pervenche', numero: 4, siege: 2, portee: 'tour', tour: 2 },
retirerReservation: { personne: 'Théo Pervenche', numero: 4, portee: 'tous', tour: null }, 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 }, 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 }) => { livrees.map(({ cle, texte }) => {
const entree = CATALOGUE.find((candidate) => candidate.cle === cle); const entree = CATALOGUE.find((candidate) => candidate.cle === cle);
if (entree === undefined) throw new RangeError(`demonstrations : « ${cle} » n'est pas au catalogue`); 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); const { plafondAPriori } = diagnostiquer(configuration);
return [ return [
entree.nom, entree.nom,

View file

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

View file

@ -10,10 +10,11 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js'; import { describe, test } from '../../test/lanceur.js';
import { nombrePlacesManquantes, normaliser } from '../moteur/configuration.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 { examiner } from '../stockage/placements.js';
import { VERSION } from '../version.genere.js'; import { VERSION } from '../version.genere.js';
import { avertissementsApres, examenNomme, invariantViole } from './avertissements.js'; import { avertissementsApres, examenNomme, invariantViole } from './avertissements.js';
import { configurationDe } from './configuration.js';
const personne = (id, nom, exclu = false) => ({ const personne = (id, nom, exclu = false) => ({
id, id,
@ -50,7 +51,7 @@ function sallePleine() {
return charge; 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 // 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 // à 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) // © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les bilans du mobilier (§ 6.1, § 6.2, § 2.1 étape 5) : ce qu'un changement // Les bilans du mobilier (§ 6.1, § 6.2, § 2.1 étape 5, § 4.5) : ce qu'un
// de défaut, de tours ou la suppression d'une table emporterait, compté sur // changement de défaut, de tours ou la suppression d'une table emporterait,
// la charge courante sans rien écrire, pour l'énoncer avant d'appliquer. La // objectifs des tables compris, compté sur la charge courante sans rien
// commande qui applique lit le même bilan : changerDefaut lève le premier de // écrire, pour l'énoncer avant d'appliquer. La commande qui applique lit le
// ses refus. Les refus et avertissements d'une capacité sont ceux de // même bilan : changerDefaut lève le premier de ses refus. Les refus et
// controlerCapacites (capacite.js), le seul contrôle. Aucune fonction ne // avertissements d'une capacité sont ceux de controlerCapacites
// modifie ce qu'elle reçoit. // (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 { ordreDesReservations } from '../csv/import.js';
import { SIEGES_MAX, capacite } from '../stockage/document.js'; import { SIEGES_MAX, capacite } from '../stockage/document.js';
import { controlerCapacites } from './capacite.js'; import { controlerCapacites } from './capacite.js';
import { ordreDesObjectifs } from './objectifs.js';
import { presents } from './places.js'; import { presents } from './places.js';
const croissant = (a, b) => a - b; 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 * 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 * 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 * 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 * 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 {import('../stockage/types.js').Charge} charge
* @param {number} tours * @param {number} tours
* @returns {{reservationsDetruites: Array<{participant: number, table: number, tour: number}>, * @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 * @throws {TypeError} tours qui n'est pas un entier
*/ */
export function bilanTours(charge, tours) { export function bilanTours(charge, tours) {
@ -111,26 +116,38 @@ export function bilanTours(charge, tours) {
.filter(({ portee, tour }) => portee === 'tour' && tour > tours) .filter(({ portee, tour }) => portee === 'tour' && tour > tours)
.sort(ordreDesReservations) .sort(ordreDesReservations)
.map(({ participant, table, tour }) => ({ participant, table, tour })), .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), propositionsTouchees: identifiants(charge.propositions, touche),
retenuTouche: charge.retenu !== null && touche(charge.retenu), retenuTouche: charge.retenu !== null && touche(charge.retenu),
}; };
} }
/** /**
* La suppression de la table id (§ 6.2, § 4) : ses réservations, suspendues * La suppression de la table id (§ 6.2, § 4, § 4.5) : ses réservations,
* comprises, dans l'ordre canonique, et ses titres par siège puis libellé, * suspendues comprises, dans l'ordre canonique, ses titres par siège puis
* qui partent avec elle ; propositionsTouchees, les identifiants croissants * libellé, et ses objectifs {tour, regle, etiquettes}, la soirée d'abord
* des propositions qui la déclarent, et retenuTouche de même — ils restent, * puis les tours croissants, qui partent avec elle ; propositionsTouchees,
* en dérive TABLE_SUPPRIMEE (§ 9). Une table nue rend deux listes vides. * 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 {import('../stockage/types.js').Charge} charge
* @param {number} id * @param {number} id
* @returns {{reservations: Array<{participant: number, portee: 'tous'|'tour', tour: number|null, siege: number|null}>, * @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 * @throws {TypeError} une table inconnue
*/ */
export function bilanSuppressionTable(charge, id) { export function bilanSuppressionTable(charge, id) {
tableDe(charge, id); const table = tableDe(charge, id);
const declare = (placement) => Array.isArray(placement?.tables) && placement.tables.includes(id); const declare = (placement) => Array.isArray(placement?.tables) && placement.tables.includes(id);
return { return {
reservations: charge.reservations reservations: charge.reservations
@ -141,6 +158,9 @@ export function bilanSuppressionTable(charge, id) {
.filter(({ table }) => table === id) .filter(({ table }) => table === id)
.sort((a, b) => a.siege - b.siege || comparerTextes(a.libelle, b.libelle)) .sort((a, b) => a.siege - b.siege || comparerTextes(a.libelle, b.libelle))
.map(({ siege, libelle }) => ({ siege, libelle })), .map(({ siege, libelle }) => ({ siege, libelle })),
objectifs: [...(table.objectifs ?? [])]
.sort(ordreDesObjectifs)
.map(({ tour, regle, etiquettes }) => ({ tour, regle, etiquettes: [...etiquettes] })),
propositionsTouchees: identifiants(charge.propositions, declare), propositionsTouchees: identifiants(charge.propositions, declare),
retenuTouche: charge.retenu !== null && declare(charge.retenu), 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: 1, table: 1, tour: 4 },
{ participant: 2, table: 2, tour: 4 }, { participant: 2, table: 2, tour: 4 },
], ],
objectifsDetruits: [],
propositionsTouchees: [1, 2], propositionsTouchees: [1, 2],
retenuTouche: true, 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", () => { test("le même nombre ne touche rien ; en ajouter ne détruit rien, mais périme les placements", () => {
const charge = quatreTours(); const charge = quatreTours();
assert.deepEqual(bilanTours(charge, 4), { reservationsDetruites: [], propositionsTouchees: [], retenuTouche: false }); const rien = { reservationsDetruites: [], objectifsDetruits: [] };
assert.deepEqual(bilanTours(charge, 5), { reservationsDetruites: [], propositionsTouchees: [1, 2], retenuTouche: true }); 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), { assert.deepEqual(bilanSuppressionTable(garnie(), 2), {
reservations: [{ participant: 2, portee: 'tous', tour: null, siege: 2 }], reservations: [{ participant: 2, portee: 'tous', tour: null, siege: 2 }],
titres: [{ siege: 2, libelle: 'animateur' }], titres: [{ siege: 2, libelle: 'animateur' }],
objectifs: [],
propositionsTouchees: [1, 2], propositionsTouchees: [1, 2],
retenuTouche: true, 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', () => { test('une table nue : deux listes vides ; une table créée après les placements ne les touche pas', () => {
const charge = garnie(); const charge = garnie();
const nue = bilanSuppressionTable(charge, 3); 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 creee = COMMANDES.get('creerTable')(charge, { forme: 'carree' }).charge;
const idCree = creee.tables.at(-1).id; const idCree = creee.tables.at(-1).id;
// creerTable fait entrer la table au retenu ; aucune proposition ne la déclare. // 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é', () => { 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 { serialiserCharge } from '../stockage/canonique.js';
import { SCHEMA, clesRangees, creerCharge, etatDeduit, premiereFaute } from '../stockage/document.js'; import { SCHEMA, clesRangees, creerCharge, etatDeduit, premiereFaute } from '../stockage/document.js';
import { ErreurStockage } from '../stockage/erreurs.js'; import { ErreurStockage } from '../stockage/erreurs.js';
import { ETIQUETTES_MAX } from '../stockage/etiquettes.js';
import { estHorodatage } from '../stockage/journal.js'; import { estHorodatage } from '../stockage/journal.js';
import { fautes, formeNommee, versFichier } from '../stockage/placements.js'; import { fautes, formeNommee, versFichier } from '../stockage/placements.js';
import { COMMANDES_MOBILIER } from './commandes_mobilier.js'; import { COMMANDES_MOBILIER } from './commandes_mobilier.js';
import { COMMANDES_PLACES } from './commandes_places.js'; import { COMMANDES_PLACES } from './commandes_places.js';
import { ErreurCommande, refus } from './erreurs.js'; import { ErreurCommande, refus } from './erreurs.js';
import { libelle } from './libelles.js'; import { libelle } from './libelles.js';
import { etiquettesDeLEvenement, etiquettesRangees, recevoirEtiquettes } from './objectifs.js';
import { nomAffiche } from './personnes.js'; import { nomAffiche } from './personnes.js';
import { couvre, repartirSieges, reservationsActives } from './places.js'; import { couvre, repartirSieges, reservationsActives } from './places.js';
import { controlerReintegration, leverRefus } from './pose.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 // 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 // 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. // les titres que le geste cesse de pourvoir, ou pourvoit de nouveau.
@ -486,6 +544,7 @@ export const COMMANDES = reunirCommandes(
new Map([ new Map([
['ajouterParticipant', ajouterParticipant], ['ajouterParticipant', ajouterParticipant],
['modifierParticipant', modifierParticipant], ['modifierParticipant', modifierParticipant],
['changerEtiquettes', changerEtiquettes],
['exclureParticipant', exclureParticipant], ['exclureParticipant', exclureParticipant],
['reintegrerParticipant', reintegrerParticipant], ['reintegrerParticipant', reintegrerParticipant],
['supprimerParticipant', supprimerParticipant], ['supprimerParticipant', supprimerParticipant],

View file

@ -11,6 +11,7 @@ import { describe, test } from '../../test/lanceur.js';
import { apercevoir } from '../csv/apercu.js'; import { apercevoir } from '../csv/apercu.js';
import { bilanRemplacement } from '../csv/import.js'; import { bilanRemplacement } from '../csv/import.js';
import { rechercher } from '../moteur/recherche.js'; import { rechercher } from '../moteur/recherche.js';
import { serialiserCharge } from '../stockage/canonique.js';
import { configurationDepuisCharge, creerCharge, etatDeduit } from '../stockage/document.js'; import { configurationDepuisCharge, creerCharge, etatDeduit } from '../stockage/document.js';
import { derive, fautes, formeNommee, planDepuisFichier } from '../stockage/placements.js'; import { derive, fautes, formeNommee, planDepuisFichier } from '../stockage/placements.js';
import { VERSION } from '../version.genere.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'; const CSV_DEUX = 'nom;prenom;appartenance\nAncolie;Bastien;Club des Merles\nBruyère;Capucine;\n';
describe('commandes : le registre (§ 8.2)', () => { 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 = [ const attendues = [
'ajouterParticipant', 'ajouterParticipant',
'changerEtat', 'changerEtat',
'changerEtiquettes',
'convertirEnTous', 'convertirEnTous',
'debloquer', 'debloquer',
'deplacerDansRetenu', 'deplacerDansRetenu',
@ -120,7 +122,7 @@ describe('commandes : le registre (§ 8.2)', () => {
'retirerTitre', 'retirerTitre',
'supprimerParticipant', 'supprimerParticipant',
]; ];
assert.equal(attendues.length, 21); assert.equal(attendues.length, 22);
assert.deepEqual([...COMMANDES.keys()].sort(), [...attendues, ...COMMANDES_MOBILIER.keys()].sort()); assert.deepEqual([...COMMANDES.keys()].sort(), [...attendues, ...COMMANDES_MOBILIER.keys()].sort());
for (const commande of COMMANDES.values()) assert.equal(typeof commande, 'function'); 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)", () => { describe("commandes : l'import (§ 10.1)", () => {
test('ajouter : chaque ligne valide devient une personne, sous le compteur, en une seule commande', () => { test('ajouter : chaque ligne valide devient une personne, sous le compteur, en une seule commande', () => {
const avant = chargeEssai(); 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' } }], ['debloquer', { ...charge, evenement: { ...charge.evenement, etat: 'bloque' } }],
['convertirEnTous', reservee], ['convertirEnTous', reservee],
['deplacerDansRetenu', executer('retirerDuTour', charge, { tour: 1, participant: autre }).charge], ['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 }], ['changerTours', { tours: 3 }],
['modifierReglage', { cle: 'nouveauxVoisins', valeur: false }], ['modifierReglage', { cle: 'nouveauxVoisins', valeur: false }],
['changerCibleRedondance', { cible: '1200' }], ['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", () => { test("COMMANDES réunit les onze commandes d'A1, changerEtiquettes et les deux registres de F1", () => {
assert.equal(COMMANDES.size, 11 + COMMANDES_MOBILIER.size + COMMANDES_PLACES.size); assert.equal(COMMANDES.size, 12 + COMMANDES_MOBILIER.size + COMMANDES_PLACES.size);
assert.ok(COMMANDES_MOBILIER instanceof Map); assert.ok(COMMANDES_MOBILIER instanceof Map);
assert.ok(COMMANDES_PLACES instanceof Map); assert.ok(COMMANDES_PLACES instanceof Map);
for (const registre of [COMMANDES_MOBILIER, COMMANDES_PLACES]) { 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) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le registre des commandes du mobilier — tables, sièges, tours, réglages, // 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 // cible de la somme de redondance, objectifs des tables et leur règle par
// commandes.js réunit aux autres. Ses // défaut (§ 4, § 4.5, § 6, § 7.1, § 8.2, § 8.9, § 9), que commandes.js réunit
// imports n'atteignent jamais commandes.js : le registre réuni dépend de lui, // aux autres. Ses imports n'atteignent jamais commandes.js : le registre
// non l'inverse. // réuni dépend de lui, non l'inverse.
// //
// Chaque commande suit le contrat de commandes.js : une fonction pure // Chaque commande suit le contrat de commandes.js : une fonction pure
// (charge, arguments) → { charge, libelle, avertissements }, la charge reçue // (charge, arguments) → { charge, libelle, avertissements }, la charge reçue
// intacte ; un refus lève ErreurCommande(code, details), details.remede // intacte ; un refus lève ErreurCommande(code, details), details.remede
// compris, calculé avant toute modification ; un argument de forme fausse ou // compris, calculé avant toute modification ; un argument de forme fausse ou
// une table inconnue lèvent TypeError, que l'interface n'offre jamais. Un // une table inconnue lèvent TypeError, que l'interface n'offre jamais, sauf
// plan bloqué refuse tout (PLAN_BLOQUE). Une saisie de places ou de tours se // pour un objectif : la table qu'il vise a pu disparaître pendant la saisie
// lit par lireEntier, la seule lecture d'un entier saisi. // 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 // 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 // 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 // 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. // table et changer les tours laissent propositions et retenu en dérive.
import { caseLibre, geometrieTable, listeSurTable } from '../geometrie/tables.js'; 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 { fautes } from '../stockage/placements.js';
import { bilanChangementDefaut } from './bilans.js'; import { bilanChangementDefaut } from './bilans.js';
import { controlerCapacite, lireEntier, retenuAuxCapacites } from './capacite.js'; import { controlerCapacite, lireEntier, retenuAuxCapacites } from './capacite.js';
import { ErreurCommande, refus } from './erreurs.js'; import { ErreurCommande, refus } from './erreurs.js';
import { libelle } from './libelles.js'; import { libelle } from './libelles.js';
import { avisDeLObjectif, avisObjectifs, etiquettesDeLEvenement, ordreDesObjectifs, recevoirEtiquettes } from './objectifs.js';
import { leverRefus } from './pose.js'; import { leverRefus } from './pose.js';
const FORMES = new Set(['ronde', 'carree']); 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). // de la génération (§ 5.3, § 5.10).
const INTERRUPTEURS = new Set(['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable', 'varierAppartenances', 'attribuerSieges']); const INTERRUPTEURS = new Set(['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable', 'varierAppartenances', 'attribuerSieges']);
const ENTIERS_DE_GENERATION = new Set(['generation.nombre', 'generation.arret', 'generation.historique']); 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). // Un plan bloqué refuse toute modification ; débloquer seul le lève (§ 9).
function exigerModifiable(charge) { function exigerModifiable(charge) {
@ -179,9 +198,9 @@ function creerTable(charge, { forme } = {}) {
/** /**
* supprimerTable {id} (§ 6.2, § 9) : la table part, et avec elle, dans la * 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 * même entrée, ses réservations — suspendues comprises —, ses titres et ses
* autres tables gardent leur numéro, propositions et retenu restent, en * objectifs, qu'elle porte ; les autres tables gardent leur numéro,
* dérive TABLE_SUPPRIMEE. * propositions et retenu restent, en dérive TABLE_SUPPRIMEE.
*/ */
function supprimerTable(charge, { id } = {}) { function supprimerTable(charge, { id } = {}) {
exigerModifiable(charge); exigerModifiable(charge);
@ -194,6 +213,7 @@ function supprimerTable(charge, { id } = {}) {
numero: table.numero, numero: table.numero,
reservations: charge.reservations.length - suivante.reservations.length, reservations: charge.reservations.length - suivante.reservations.length,
titres: charge.titres.length - suivante.titres.length, titres: charge.titres.length - suivante.titres.length,
objectifs: table.objectifs?.length ?? 0,
}; };
return { charge: suivante, libelle: libelle('ENTREE_SUPPRIMER_TABLE', details), avertissements: [] }; 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 * changerTours {tours} (§ 2.1 étape 5, § 4.3, § 4.5) : le nombre de tours,
* lireEntier ; MOINS_D_UN_TOUR {tours} sous 1, TROP_DE_TOURS {max} au-delà * lu par lireEntier ; MOINS_D_UN_TOUR {tours} sous 1, TROP_DE_TOURS {max}
* de TOURS_MAX, la borne que l'analyse pose aussi, remède null. Retirer des tours * au-delà de TOURS_MAX, la borne que l'analyse pose aussi, remède null.
* détruit les réservations « tour désigné » au-delà, dans la même entrée ; * Retirer des tours détruit, dans la même entrée, les réservations « tour
* la portée « tous » reste, symbolique. Propositions et retenu restent, en * désigné » et les objectifs de table de ces tours, que le libellé compte ;
* dérive TOURS_CHANGES, jamais tronqués. * 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 } = {}) { function changerTours(charge, { tours: saisie } = {}) {
exigerModifiable(charge); exigerModifiable(charge);
@ -296,16 +319,31 @@ function changerTours(charge, { tours: saisie } = {}) {
const suivante = structuredClone(charge); const suivante = structuredClone(charge);
suivante.evenement.tours = tours; suivante.evenement.tours = tours;
suivante.reservations = suivante.reservations.filter(({ portee, tour }) => portee === 'tous' || tour <= 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 { return {
charge: suivante, 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: [], avertissements: [],
}; };
} }
/** /**
* modifierReglage {cle, valeur} (§ 5.3, § 15.4) : un interrupteur des * modifierReglage {cle, valeur} (§ 5.3, § 8.9, § 15.4) : un interrupteur des
* contraintes ou de l'attribution des sièges, un booléen ; ou generation.nombre, * 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 * 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 * de génération a lu et borné. Une autre clé, ou une autre valeur, est une
* faute du code. * faute du code.
@ -316,6 +354,11 @@ function modifierReglage(charge, { cle, valeur } = {}) {
if (INTERRUPTEURS.has(cle)) { if (INTERRUPTEURS.has(cle)) {
if (typeof valeur !== 'boolean') throw new TypeError(`${cle} : booléen attendu, reçu ${JSON.stringify(valeur)}`); if (typeof valeur !== 'boolean') throw new TypeError(`${cle} : booléen attendu, reçu ${JSON.stringify(valeur)}`);
suivante.reglages[cle] = 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)) { } 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)}`); 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; 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: [] }; 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>} */ /** @type {Map<string, (charge: Object, args: Object) => Object>} */
export const COMMANDES_MOBILIER = new Map([ export const COMMANDES_MOBILIER = new Map([
['creerTable', creerTable], ['creerTable', creerTable],
@ -357,4 +520,7 @@ export const COMMANDES_MOBILIER = new Map([
['changerTours', changerTours], ['changerTours', changerTours],
['modifierReglage', modifierReglage], ['modifierReglage', modifierReglage],
['changerCibleRedondance', changerCibleRedondance], ['changerCibleRedondance', changerCibleRedondance],
['poserObjectif', poserObjectif],
['retirerObjectif', retirerObjectif],
['changerRegleObjectifs', changerRegleObjectifs],
]); ]);

View file

@ -214,7 +214,7 @@ async function seanceSur(charge) {
// --- Les épreuves ----------------------------------------------------------- // --- Les épreuves -----------------------------------------------------------
describe('mobilier : le registre (§ 8.2)', () => { 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 = [ const noms = [
'creerTable', 'creerTable',
'supprimerTable', 'supprimerTable',
@ -225,6 +225,9 @@ describe('mobilier : le registre (§ 8.2)', () => {
'changerTours', 'changerTours',
'modifierReglage', 'modifierReglage',
'changerCibleRedondance', 'changerCibleRedondance',
'poserObjectif',
'retirerObjectif',
'changerRegleObjectifs',
]; ];
assert.deepEqual([...COMMANDES_MOBILIER.keys()], noms); assert.deepEqual([...COMMANDES_MOBILIER.keys()], noms);
for (const nom of noms) assert.equal(COMMANDES.get(nom), COMMANDES_MOBILIER.get(nom), nom); 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 }], ['changerTours', { tours: 3 }],
['modifierReglage', { cle: 'nouveauxVoisins', valeur: false }], ['modifierReglage', { cle: 'nouveauxVoisins', valeur: false }],
['changerCibleRedondance', { cible: '1200' }], ['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); assert.equal(gestes.length, COMMANDES_MOBILIER.size);
for (const [nom, args] of gestes) { 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); 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) // 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 // 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 // l'application, jamais par src/csv. Ce module réexporte les fonctions et
// src/csv dont un écran a besoin, telles quelles, et lie le motif du CSV des // les constantes de src/csv dont un écran a besoin, telles quelles — la
// refus au libellé français : l'interface ne choisit pas ce motif, qui est // jointure des étiquettes comprise, qu'un écran écrit comme l'export —, et
// une donnée du fichier écrit, jamais un texte de la langue témoin. // 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 { exporterRefus as exporterRefusCsv } from '../csv/import.js';
import { libelle } from './libelles.js'; import { libelle } from './libelles.js';
export { apercevoir } from '../csv/apercu.js'; export { apercevoir } from '../csv/apercu.js';
export { CHAMPS } from '../csv/colonnes.js'; export { CHAMPS } from '../csv/colonnes.js';
export { decoder } from '../csv/encodage.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 { bilanRemplacement } from '../csv/import.js';
export { cleNormalisee } from '../csv/normalisation.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 // É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 // 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 // et lie le motif du CSV des refus au libellé français ; les textes des
// inventés. // deux refus d'une ligne pour ses étiquettes. Les noms sont inventés.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js'; import { describe, test } from '../../test/lanceur.js';
import * as apercu from '../csv/apercu.js'; import * as apercu from '../csv/apercu.js';
@ -18,7 +18,7 @@ import * as relais from './csv.js';
import { libelle } from './libelles.js'; import { libelle } from './libelles.js';
describe('csv : le relais de src/csv pour l’interface (§ 13.4)', () => { 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 = [ const attendus = [
['decoder', encodage.decoder], ['decoder', encodage.decoder],
['apercevoir', apercu.apercevoir], ['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[nom], fonction, nom);
} }
assert.equal(relais.CHAMPS, colonnes.CHAMPS); 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(), [ 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); 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, // 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, // 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 // 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). // le stockage admet et que le moteur refuse (CONFIGURATION_INCOHERENTE, A2).
@ -12,7 +12,7 @@
// autre erreur est une faute du code et remonte. // autre erreur est une faute du code et remonte.
import { diagnostiquer } from '../moteur/diagnostic.js'; import { diagnostiquer } from '../moteur/diagnostic.js';
import { ErreurConfiguration } from '../moteur/erreurs.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 * 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) { export function diagnostiquerCharge(charge) {
try { try {
return { diagnostic: diagnostiquer(configurationDepuisCharge(charge)), erreur: null }; return { diagnostic: diagnostiquer(configurationDe(charge)), erreur: null };
} catch (erreur) { } catch (erreur) {
if (!(erreur instanceof ErreurConfiguration)) throw erreur; if (!(erreur instanceof ErreurConfiguration)) throw erreur;
return { diagnostic: null, erreur: { code: erreur.code, details: erreur.details } }; 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 { ErreurConfiguration } from '../moteur/erreurs.js';
import { normaliser } from '../moteur/configuration.js'; import { normaliser } from '../moteur/configuration.js';
import { serialiser } from '../stockage/canonique.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 { VERSION } from '../version.genere.js';
import { configurationDe } from './configuration.js';
import { diagnostiquerCharge } from './diagnostic.js'; import { diagnostiquerCharge } from './diagnostic.js';
const chargeDemo = (cle) => analyser(texteLivre(cle)).charge; const chargeDemo = (cle) => analyser(texteLivre(cle)).charge;
@ -30,7 +31,7 @@ describe('diagnostiquerCharge', () => {
test('petite démonstration : le diagnostic de sa configuration, erreur null', () => { test('petite démonstration : le diagnostic de sa configuration, erreur null', () => {
const charge = chargeDemo('petite'); const charge = chargeDemo('petite');
const rendu = diagnostiquerCharge(charge); 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); assert.ok(rendu.diagnostic.plafondAPriori.repartition.length > 0);
}); });
@ -39,7 +40,7 @@ describe('diagnostiquerCharge', () => {
const { diagnostic, erreur } = diagnostiquerCharge(charge); const { diagnostic, erreur } = diagnostiquerCharge(charge);
assert.equal(erreur, null); assert.equal(erreur, null);
assert.equal(diagnostic.collisions.length, 1); 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', () => { 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); assert.equal(relue.reservations.length, 4);
let attendue = null; let attendue = null;
try { try {
normaliser(configurationDepuisCharge(relue)); normaliser(configurationDe(relue));
} catch (erreur) { } catch (erreur) {
assert.ok(erreur instanceof ErreurConfiguration); assert.ok(erreur instanceof ErreurConfiguration);
attendue = erreur; attendue = erreur;
@ -64,7 +65,7 @@ describe('diagnostiquerCharge', () => {
const charge = creerCharge({ id: 'evt-neuf', nom: 'Club des Merles', siegesParDefaut: 8, tours: 4 }); const charge = creerCharge({ id: 'evt-neuf', nom: 'Club des Merles', siegesParDefaut: 8, tours: 4 });
const { diagnostic, erreur } = diagnostiquerCharge(charge); const { diagnostic, erreur } = diagnostiquerCharge(charge);
assert.equal(erreur, null); 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', () => { 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 { plafondsRealises } from '../moteur/plafond.js';
import { verifierIndicateurs, verifierInvariants } from '../moteur/verification.js'; import { verifierIndicateurs, verifierInvariants } from '../moteur/verification.js';
import { ErreurConfiguration } from '../moteur/erreurs.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 { derive, deriveSansRevalider, fautes, planDepuisFichier, planSansRevalider } from '../stockage/placements.js';
import { configurationDe } from './configuration.js';
export { CIBLE_REDONDANCE_PAR_DEFAUT, cibleRedondance }; export { CIBLE_REDONDANCE_PAR_DEFAUT, cibleRedondance };
@ -115,7 +116,7 @@ export function evaluer(charge, { plafondsAPriori = 'calcul' } = {}) {
if (plafondsAPriori !== 'calcul' && plafondsAPriori !== null) { if (plafondsAPriori !== 'calcul' && plafondsAPriori !== null) {
throw new TypeError(`evaluer : plafondsAPriori 'calcul' ou null attendu, reçu ${JSON.stringify(plafondsAPriori)}`); 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 diagnostic = diagnostiquer(configuration);
const instance = normaliser(configuration); const instance = normaliser(configuration);
const aPriori = plafondsAPriori === 'calcul' ? diagnostic.plafondAPriori.parPersonne : null; const aPriori = plafondsAPriori === 'calcul' ? diagnostic.plafondAPriori.parPersonne : null;
@ -178,7 +179,7 @@ export function resumeDuRetenu(charge) {
if (retenu === null || !seCompare(retenu, charge)) return null; if (retenu === null || !seCompare(retenu, charge)) return null;
let instance; let instance;
try { try {
instance = normaliser(configurationDepuisCharge(charge)); instance = normaliser(configurationDe(charge));
} catch (erreur) { } catch (erreur) {
if (erreur instanceof ErreurConfiguration) return null; if (erreur instanceof ErreurConfiguration) return null;
throw erreur; throw erreur;

View file

@ -19,10 +19,11 @@ import { mesurer } from '../moteur/indicateurs.js';
import { ecartsAuPlafondAPriori } from '../moteur/manque.js'; import { ecartsAuPlafondAPriori } from '../moteur/manque.js';
import { plafondsAPriori } from '../moteur/plafond.js'; import { plafondsAPriori } from '../moteur/plafond.js';
import { rechercher } from '../moteur/recherche.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 { fautes, planDepuisFichier } from '../stockage/placements.js';
import { VERSION } from '../version.genere.js'; import { VERSION } from '../version.genere.js';
import { appliquerCommande } from './commandes.js'; import { appliquerCommande } from './commandes.js';
import { configurationDe } from './configuration.js';
import { import {
CIBLE_REDONDANCE_PAR_DEFAUT, CIBLE_REDONDANCE_PAR_DEFAUT,
cibleRedondance, 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. // La charge, une génération du moteur de plus, enregistrée par la commande.
function generee(charge, reglages) { function generee(charge, reglages) {
const configuration = configurationDepuisCharge(charge); const configuration = configurationDe(charge);
const propositions = rechercher(configuration, reglages); const propositions = rechercher(configuration, reglages);
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge; 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', () => { 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 charge = figer(generee(chargeDemo('petite-conflit'), { graine: 5, nombre: 3, arret: 400 }));
const table = evaluer(charge); const table = evaluer(charge);
const instance = normaliser(configurationDepuisCharge(charge)); const instance = normaliser(configurationDe(charge));
const aPriori = plafondsAPriori(instance); const aPriori = plafondsAPriori(instance);
assert.deepEqual(table.population, instance.ids); assert.deepEqual(table.population, instance.ids);
assert.equal(table.propositions.length, 3); 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.ordre, [3, 2, 1, 4]);
assert.deepEqual(table.classement.criteresAppliques[0], 'ecartAuPlafondAPrioriMax'); assert.deepEqual(table.classement.criteresAppliques[0], 'ecartAuPlafondAPrioriMax');
for (const ligne of table.propositions) { 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)); 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.prochainsIds.proposition = 2;
charge.evenement.etat = 'retenu'; charge.evenement.etat = 'retenu';
const table = evaluer(figer(charge)); 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(plancher, 4);
assert.equal(table.retenu.statut, 'comparable'); assert.equal(table.retenu.statut, 'comparable');
assert.equal(table.retenu.id, 1); 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', () => { test('la grande démonstration : 99 retours imposés, 33 ancrages qui en imposent, ceux de diagnostiquer', () => {
const charge = chargeDemo('grande'); const charge = chargeDemo('grande');
const table = evaluer(figer(charge)); 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: 99, ancrages: 33 });
assert.deepEqual(table.imposes, { retours: diagnostic.retoursImposes, ancrages: diagnostic.ancragesImposantRetours }); 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; const { mesures } = evaluer(charge).retenu;
assert.ok(mesures.totalRedondance !== 1000, 'la différence se distingue de zéro'); assert.ok(mesures.totalRedondance !== 1000, 'la différence se distingue de zéro');
assert.deepEqual(resume, { diffRedondance: Math.abs(1000 - mesures.totalRedondance) }); 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))); const attendues = mesurer(instance, indexerPlan(instance, planDepuisFichier(charge.retenu)));
assert.equal(attendues.totalRedondance, mesures.totalRedondance); assert.equal(attendues.totalRedondance, mesures.totalRedondance);
assert.equal(CIBLE_REDONDANCE_PAR_DEFAUT, 1000); 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 { ErreurAnnulee, ErreurConfiguration } from '../moteur/erreurs.js';
import { rechercher, verifierReglages } from '../moteur/recherche.js'; import { rechercher, verifierReglages } from '../moteur/recherche.js';
import { verifierInvariants } from '../moteur/verification.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 { derive, planDepuisFichier, versFichier } from '../stockage/placements.js';
import { lireEntier } from './capacite.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. // src/interface reçoit l'annulation d'ici : elle ne joint pas le moteur.
export { ErreurAnnulee } from '../moteur/erreurs.js'; 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') }); throw new ErreurGeneration('AUCUN_PARTICIPANT', { remede: versSection('participants') });
} }
if (charge.tables.length === 0) throw new ErreurGeneration('AUCUNE_TABLE', { remede: versSection('tables') }); if (charge.tables.length === 0) throw new ErreurGeneration('AUCUNE_TABLE', { remede: versSection('tables') });
const configuration = configurationDepuisCharge(charge); const configuration = configurationDe(charge);
let instance; let instance;
try { try {
instance = normaliser(configuration); instance = normaliser(configuration);

View file

@ -19,11 +19,12 @@ import { rechercher, regenerer } from '../moteur/recherche.js';
import { verifierInvariants } from '../moteur/verification.js'; import { verifierInvariants } from '../moteur/verification.js';
import { serialiser } from '../stockage/canonique.js'; import { serialiser } from '../stockage/canonique.js';
import { creer as creerDansLeDepot } from '../stockage/depot.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 { lireJournal } from '../stockage/journal.js';
import { SUFFIXES } from '../stockage/noms.js'; import { SUFFIXES } from '../stockage/noms.js';
import { derive, planDepuisFichier, versFichier } from '../stockage/placements.js'; import { derive, planDepuisFichier, versFichier } from '../stockage/placements.js';
import { VERSION } from '../version.genere.js'; import { VERSION } from '../version.genere.js';
import { configurationDe } from './configuration.js';
import { ErreurCommande } from './erreurs.js'; import { ErreurCommande } from './erreurs.js';
import { creerExecuteurTravailleur } from './executeur_travailleur.js'; import { creerExecuteurTravailleur } from './executeur_travailleur.js';
import { 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", () => { 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 () => { 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 { charge } = analyser(texteLivre('petite'));
const configuration = configurationDepuisCharge(charge); const configuration = configurationDe(charge);
const { resultat } = creerExecuteurDirect().lancer(configuration, { graine: 2 ** 32, nombre: 1, arret: 10 }); const { resultat } = creerExecuteurDirect().lancer(configuration, { graine: 2 ** 32, nombre: 1, arret: 10 });
await refuse(resultat, 'REGLAGE_HORS_DOMAINE', { reglage: 'graine', remede: null }); 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 })] }); const sansTable = await monter({ charges: [chargeDe({ n: 3, tables: 0 })] });
await refuseSansRien(sansTable, 'AUCUNE_TABLE', { remede: VERS_TABLES }); 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. // 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, [[[], []], [[], []]]); 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] }); const montage = await monter({ charges: [charge] });
let attendu = null; let attendu = null;
try { try {
normaliser(configurationDepuisCharge(charge)); normaliser(configurationDe(charge));
} catch (erreur) { } catch (erreur) {
attendu = 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.equal(avancements, 10);
assert.deepEqual(bilan.ajoutees, [1, 2]); assert.deepEqual(bilan.ajoutees, [1, 2]);
assert.equal(bilan.dureeMs, 10 * pas); assert.equal(bilan.dureeMs, 10 * pas);
const configuration = configurationDepuisCharge(montage.charge()); const configuration = configurationDe(montage.charge());
const attendues = rechercher(configuration, reglages); const attendues = rechercher(configuration, reglages);
assert.equal(attendues.length, 2); assert.equal(attendues.length, 2);
montage.charge().propositions.forEach((proposition, rang) => { 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 }; const reglages = { graine: 41, nombre: 3, arret: 300, historique: 50 };
await montage.controleur.generer(reglages); await montage.controleur.generer(reglages);
const charge = montage.charge(); const charge = montage.charge();
const configuration = configurationDepuisCharge(charge); const configuration = configurationDe(charge);
const moteur = rechercher(configuration, reglages); const moteur = rechercher(configuration, reglages);
assert.equal(charge.propositions.length, 3); assert.equal(charge.propositions.length, 3);
charge.propositions.forEach((proposition, rang) => { 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 }); const bilan = await montage.controleur.generer({ graine: 3, nombre: 2, arret: 20000 });
assert.deepEqual(bilan.ajoutees, [1, 2]); assert.deepEqual(bilan.ajoutees, [1, 2]);
const charge = montage.charge(); const charge = montage.charge();
const instance = normaliser(configurationDepuisCharge(charge)); const instance = normaliser(configurationDe(charge));
assert.ok(instance.k > 0, 'aucun ancré'); assert.ok(instance.k > 0, 'aucun ancré');
for (const proposition of charge.propositions) assert.deepEqual(verifierInvariants(instance, planDepuisFichier(proposition)), []); 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] }); const montage = await monter({ charges: [charge] });
await montage.controleur.generer({ graine: 8, nombre: 3, arret: 2000 }); await montage.controleur.generer({ graine: 8, nombre: 3, arret: 2000 });
const apres = montage.charge(); const apres = montage.charge();
const instance = normaliser(configurationDepuisCharge(apres)); const instance = normaliser(configurationDe(apres));
assert.ok(apres.propositions.length > 0); assert.ok(apres.propositions.length > 0);
for (const proposition of apres.propositions) { for (const proposition of apres.propositions) {
const plan = planDepuisFichier(proposition); const plan = planDepuisFichier(proposition);
@ -909,7 +910,7 @@ describe("génération : l'annulation (§ 5.10, § 8.4)", () => {
// --- L'exécuteur direct (§ 5.10) ----------------------------------------------------- // --- L'exécuteur direct (§ 5.10) -----------------------------------------------------
describe("génération : 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 () => { test('annuler aussitôt après lancer : ErreurAnnulee, aucun avancement', async () => {
const execution = creerExecuteurDirect().lancer(configuration, { graine: 1, nombre: 2, arret: 5000 }); 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 () => { 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); const leve = await rejet(creerExecuteurDirect().lancer(manque, COURT).resultat);
assert.ok(leve instanceof ErreurGeneration); assert.ok(leve instanceof ErreurGeneration);
assert.deepEqual([leve.code, leve.details], ['PLACES_MANQUANTES', { placesManquantes: 1 }]); 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); const bilan = await montage.controleur.generer(reglages);
assert.deepEqual(bilan.ajoutees, [1, 2, 3, 4, 5]); assert.deepEqual(bilan.ajoutees, [1, 2, 3, 4, 5]);
assert.deepEqual(montage.espion.lancements.at(-1).options, { coeurs }); 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( assert.deepEqual(
montage.charge().propositions.map(({ graine }) => graine), montage.charge().propositions.map(({ graine }) => graine),
attendues.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); assert.equal(montage.controleur.derniereGeneration().coeurs, coeurs);
etats.push(montage.etatDisque()); 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, 1);
assert.equal(creerExecuteurDirect({ coeursDisponibles: 12 }).coeursDisponibles, 12); assert.equal(creerExecuteurDirect({ coeursDisponibles: 12 }).coeursDisponibles, 12);
assert.equal(creerExecuteurDirect({ coeursDisponibles: 0 }).coeursDisponibles, 1); 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 reglages = { graine: 4, nombre: 3, arret: 2000 };
const execution = creerExecuteurDirect({ coeursDisponibles: 4 }).lancer(configuration, reglages, { coeurs: 3 }); const execution = creerExecuteurDirect({ coeursDisponibles: 4 }).lancer(configuration, reglages, { coeurs: 3 });
assert.equal(execution.coeurs, 1); assert.equal(execution.coeurs, 1);

View file

@ -339,6 +339,8 @@ const EXEMPLES = new Map([
['CHAMPS_EN_TROP', { ligne: 4, valeur: null }], ['CHAMPS_EN_TROP', { ligne: 4, valeur: null }],
['NOM_ABSENT', { ligne: 5, valeur: null }], ['NOM_ABSENT', { ligne: 5, valeur: null }],
['EXCLU_INCONNU', { ligne: 6, valeur: 'peut-être' }], ['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] }], ['PLUSIEURS_CORRESPONDENT', { ligne: 7, valeur: null, participants: [3, 8] }],
['DEJA_DESIGNE', { ligne: 9, valeur: null, participants: [3], premiereLigne: 4 }], ['DEJA_DESIGNE', { ligne: 9, valeur: null, participants: [3], premiereLigne: 4 }],
['PLAN_BLOQUE', { remede: { geste: 'debloquer' } }], ['PLAN_BLOQUE', { remede: { geste: 'debloquer' } }],
@ -412,6 +414,16 @@ const EXEMPLES = new Map([
['ORDRE_PLAFONDS', { index: 2, participant: 3 }], ['ORDRE_PLAFONDS', { index: 2, participant: 3 }],
['PLAFOND_REALISE', { index: 0, participant: 1 }], ['PLAFOND_REALISE', { index: 0, participant: 1 }],
['DEPASSEMENT', { index: 4, participant: 9 }], ['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 // Génération
['GENERATION_EN_COURS', { remede: null }], ['GENERATION_EN_COURS', { remede: null }],
['TRAVAILLEUR_EN_ECHEC', { 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_RETABLIES', { participant: 1, personne: 'Iris Ombrelle', reservations: 2, titres: 1 }],
['RESERVATIONS_RETIREES', { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 }], ['RESERVATIONS_RETIREES', { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 }],
['IMPORT_EN_RETENU', {}], ['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 // Façade des événements
['QUESTION_EN_ATTENTE', { question: 'PORTABLE_NON_INSCRIPTIBLE', remede: null }], ['QUESTION_EN_ATTENTE', { question: 'PORTABLE_NON_INSCRIPTIBLE', remede: null }],
['EXPLORATEUR_ABSENT', { remede: null }], ['EXPLORATEUR_ABSENT', { remede: null }],
@ -472,6 +487,7 @@ const EXEMPLES = new Map([
['ENTREE_EXCLURE_PARTICIPANT', { personne: 'Iris Ombrelle' }], ['ENTREE_EXCLURE_PARTICIPANT', { personne: 'Iris Ombrelle' }],
['ENTREE_REINTEGRER_PARTICIPANT', { personne: 'Iris Ombrelle' }], ['ENTREE_REINTEGRER_PARTICIPANT', { personne: 'Iris Ombrelle' }],
['ENTREE_SUPPRIMER_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_IMPORTER_PARTICIPANTS', { mode: 'ajouter', ajoutes: 12, misAJour: 0, retires: 0 }],
['ENTREE_CHANGER_ETAT', { etat: 'bloque' }], ['ENTREE_CHANGER_ETAT', { etat: 'bloque' }],
['ENTREE_DEBLOQUER', { etat: 'retenu' }], ['ENTREE_DEBLOQUER', { etat: 'retenu' }],
@ -541,6 +557,15 @@ const EXEMPLES = new Map([
['ENTREE_CHANGER_TOURS', { tours: 3, retirees: 2 }], ['ENTREE_CHANGER_TOURS', { tours: 3, retirees: 2 }],
['ENTREE_MODIFIER_REGLAGE', { cle: 'separerAppartenances', valeur: false }], ['ENTREE_MODIFIER_REGLAGE', { cle: 'separerAppartenances', valeur: false }],
['ENTREE_CHANGER_CIBLE_REDONDANCE', { cible: 1200 }], ['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 de badges
['PLANCHE_SANS_PLACEMENT', { remede: { geste: 'ouvrirSection', section: 'propositions' } }], ['PLANCHE_SANS_PLACEMENT', { remede: { geste: 'ouvrirSection', section: 'propositions' } }],
['PROPOSITION_FAUTIVE', { id: 2, 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/); 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, // 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. // 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). // 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 REMEDE_UTF8 = 'Réenregistrez le fichier en UTF-8 depuis le tableur, puis importez-le de nouveau.';
const refusEncodage = (constat) => () => `${constat} ${REMEDE_UTF8}`; 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([ export const TABLE = new Map([
// --- Le CSV (§ 10.1) ------------------------------------------------------- // --- Le CSV (§ 10.1) -------------------------------------------------------
['UTF16_SANS_MARQUE', refusEncodage("Le fichier semble encodé en UTF-16, sans la marque qui l'annonce.")], ['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 : ` + `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.', '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', 'PLUSIEURS_CORRESPONDENT',
({ participants }) => ({ participants }) =>

View file

@ -1,10 +1,11 @@
// © 2026 TechnoLibre (http://www.technolibre.ca) // © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les textes des commandes du mobilier — tables, sièges, tours, réglages // 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 // objectifs des tables (§ 4, § 4.5, § 6, § 7.1, § 8.2, § 14.6) : les refus
// de tours, leurs avertissements, les causes d'un siège retiré et les // d'une capacité, d'un nombre de tours et d'un objectif, leurs
// libellés figés du journal. Les détails désignent tables et personnes par // 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 // 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 // les nomme pas. Un libellé figé reçoit le numéro de table que la commande
// lit dans la charge. // lit dans la charge.
@ -21,8 +22,25 @@ const NOMS_DES_REGLAGES = new Map([
['generation.nombre', 'Propositions par génération'], ['generation.nombre', 'Propositions par génération'],
['generation.arret', 'Mouvements par proposition'], ['generation.arret', 'Mouvements par proposition'],
['generation.historique', "Historique d'acceptation"], ['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 // La valeur d'un réglage dans un libellé : activé ou désactivé pour un
// interrupteur, le nombre sinon. // interrupteur, le nombre sinon.
const valeurDeReglage = (valeur) => (typeof valeur === 'boolean' ? (valeur ? 'activé' : 'désactivé') : String(valeur)); 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.`, `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) --------------------------------- // --- Les libellés figés du journal (§ 8.2) ---------------------------------
['ENTREE_CREER_TABLE', ({ numero }) => `Ajouté la table ${numero}`], ['ENTREE_CREER_TABLE', ({ numero }) => `Ajouté la table ${numero}`],
[ [
'ENTREE_SUPPRIMER_TABLE', 'ENTREE_SUPPRIMER_TABLE',
({ numero, reservations, titres }) => { ({ numero, reservations, titres, objectifs = 0 }) => {
const retires = []; const retires = [];
if (reservations > 0) retires.push(compte(reservations, 'réservation', 'réservations')); if (reservations > 0) retires.push(compte(reservations, 'réservation', 'réservations'));
if (titres > 0) retires.push(compte(titres, 'titre', 'titres')); 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)}` : ''}`; return `Supprimé la table ${numero}${retires.length > 0 ? ` ; retirés : ${enumeration(retires)}` : ''}`;
}, },
], ],
@ -112,12 +156,27 @@ export const TABLE = new Map([
], ],
[ [
'ENTREE_CHANGER_TOURS', 'ENTREE_CHANGER_TOURS',
({ tours, retirees }) => ({ tours, retirees, objectifsRetires = 0 }) => {
`Nombre de tours : ${tours}${retirees > 0 ? ` ; ${compte(retirees, 'réservation retirée', 'réservations retirées')}` : ''}`, 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', 'ENTREE_MODIFIER_REGLAGE',
({ cle, valeur }) => `${nomDans(NOMS_DES_REGLAGES, cle, 'réglage')} : ${valeurDeReglage(valeur)}`, ({ cle, valeur }) => `${nomDans(NOMS_DES_REGLAGES, cle, 'réglage')} : ${valeurDeReglage(valeur)}`,
], ],
['ENTREE_CHANGER_CIBLE_REDONDANCE', ({ cible }) => `Cible de la somme de redondance : ${cible}`], ['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 // libellés de ses critères (§ 5, § 14.6) : les codes d'une
// ErreurConfiguration, ceux des violations de verifierInvariants et de // ErreurConfiguration, ceux des violations de verifierInvariants et de
// verifierIndicateurs, et moteur.critere.<nom> pour chaque nom de CRITERES. // 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 // Un code garde un seul texte : CAPACITE et TOURS, que le moteur lève sous
// deux formes de détails — ErreurConfiguration et violation —, se // deux formes de détails — ErreurConfiguration et violation —, se
// distinguent par les détails qu'ils portent. Une réservation que le moteur // 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. // Un rang de réservation, compté à partir de 0 par le moteur.
const reservationDe = (rang) => `la réservation n° ${rang + 1}`; 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([ export const TABLE = new Map([
// Une salle trop petite : avertissement d'un geste qui fait croître le // 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. // 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, ` + `La personne n° ${participant} est réservée au tour ${tour} à deux tables, ` +
`d'identifiants ${tables[0]} et ${tables[1]}.`, `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', 'ENUMERATION_TROP_GRANDE',
({ participant, limite }) => ({ participant, limite }) =>
@ -144,6 +181,23 @@ export const TABLE = new Map([
'DEPASSEMENT', 'DEPASSEMENT',
(details) => `Les rencontres de ${personneDe(details)} dépassent son plafond réalisé : le calcul est faux.`, (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) ----------------------------------------- // --- Les critères du classement (§ 5.7) -----------------------------------------
['moteur.critere.ecartAuPlafondAPrioriMax', 'écart au plafond a priori'], ['moteur.critere.ecartAuPlafondAPrioriMax', 'écart au plafond a priori'],

View file

@ -1,11 +1,14 @@
// © 2026 TechnoLibre (http://www.technolibre.ca) // © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les textes des commandes de la liste des participants et de l'import // Les textes des commandes de la liste des participants, de leurs
// (§ 4.4, § 10.2, § 14.6) : leurs refus, leurs avertissements et leurs // étiquettes et de l'import des participants (§ 4.4, § 4.5, § 10.2,
// libellés figés. Cette table ne porte que des codes. Un champ inconnu lève // § 14.6) : leurs refus, leurs avertissements et leurs libellés figés. Cette
// RangeError, au lieu de se taire. // table ne porte que des codes. Un champ ou une raison inconnus lèvent
import { compte, nomDans } from './aides.js'; // 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. // Les champs d'un participant qu'une saisie modifie, dans l'ordre du fichier.
const NOMS_DES_CHAMPS = new Map([ 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'); 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. // Les titres de place qu'un geste sur une personne touche, quand il y en a.
const titresDe = (n, singulier, pluriel) => const titresDe = (n, singulier, pluriel) =>
n > 0 ? `, et ${compte(n, 'titre de place', 'titres de place')} ${n <= 1 ? 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')}` + `Suppression de ${personne} : ${compte(reservations, 'réservation retirée', 'réservations retirées')}` +
`${titresDe(titres, "n'est plus pourvu", 'ne sont plus pourvus')}.`, `${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', 'IMPORT_EN_RETENU',
() => () =>
@ -63,6 +95,15 @@ export const TABLE = new Map([
['ENTREE_EXCLURE_PARTICIPANT', ({ personne }) => `Exclu ${personne}`], ['ENTREE_EXCLURE_PARTICIPANT', ({ personne }) => `Exclu ${personne}`],
['ENTREE_REINTEGRER_PARTICIPANT', ({ personne }) => `Réintégré ${personne}`], ['ENTREE_REINTEGRER_PARTICIPANT', ({ personne }) => `Réintégré ${personne}`],
['ENTREE_SUPPRIMER_PARTICIPANT', ({ personne }) => `Supprimé ${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', 'ENTREE_IMPORTER_PARTICIPANTS',
({ mode, ajoutes, misAJour, retires }) => { ({ 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 { STATUT, indexerPlan, normaliser } from '../moteur/configuration.js';
import { conflitsParTable } from '../moteur/conflits.js'; import { conflitsParTable } from '../moteur/conflits.js';
import { ErreurConfiguration } from '../moteur/erreurs.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 { derive, fautes, planDepuisFichier } from '../stockage/placements.js';
import { configurationDe } from './configuration.js';
import { libelle } from './libelles.js'; import { libelle } from './libelles.js';
const croissant = (a, b) => a - b; const croissant = (a, b) => a - b;
@ -41,7 +42,7 @@ export function reservationsActives(charge) {
*/ */
export function instanceDe(charge) { export function instanceDe(charge) {
try { try {
return normaliser(configurationDepuisCharge(charge)); return normaliser(configurationDe(charge));
} catch (erreur) { } catch (erreur) {
if (erreur instanceof ErreurConfiguration) return null; if (erreur instanceof ErreurConfiguration) return null;
throw erreur; throw erreur;
@ -364,7 +365,7 @@ const NOMS_DES_STATUTS = new Map([
export function statuts(charge) { export function statuts(charge) {
let instance; let instance;
try { try {
instance = normaliser(configurationDepuisCharge(charge)); instance = normaliser(configurationDe(charge));
} catch (erreur) { } catch (erreur) {
if (erreur instanceof ErreurConfiguration) return { parId: new Map(), erreur: erreur.code }; if (erreur instanceof ErreurConfiguration) return { parId: new Map(), erreur: erreur.code };
throw erreur; throw erreur;

View file

@ -11,10 +11,11 @@ import { readFileSync } from 'node:fs';
import { describe, test } from '../../test/lanceur.js'; import { describe, test } from '../../test/lanceur.js';
import { STATUT, indexerPlan, normaliser } from '../moteur/configuration.js'; import { STATUT, indexerPlan, normaliser } from '../moteur/configuration.js';
import { conflitsParTable } from '../moteur/conflits.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 { planDepuisFichier } from '../stockage/placements.js';
import { VERSION } from '../version.genere.js'; import { VERSION } from '../version.genere.js';
import { COMMANDES } from './commandes.js'; import { COMMANDES } from './commandes.js';
import { configurationDe } from './configuration.js';
import { titresNonPourvus } from './listes_travail.js'; import { titresNonPourvus } from './listes_travail.js';
import { etatPlaces, libellePropose, repartirSieges, statuts } from './places.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. // Les conflits que le moteur lit sur ce placement, tour par tour.
function conflitsDuMoteur(charge, placement) { function conflitsDuMoteur(charge, placement) {
const instance = normaliser(configurationDepuisCharge(charge)); const instance = normaliser(configurationDe(charge));
return conflitsParTable(instance, indexerPlan(instance, planDepuisFichier(placement))); 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)]; charge.reservations = [resa(1, 1, null), resa(5, 1, null, 2)];
const etat = etatPlaces(charge, { tour: 2 }); const etat = etatPlaces(charge, { tour: 2 });
assert.equal(etat.evalue, true); 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.deepEqual(etat.tables[0].conflits, (({ nombre, regles }) => ({ nombre, regles }))(conflitsParTable(instance, instance.fixe)[1][0]));
assert.ok(etat.tables[0].conflits.nombre > 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); const { parId, erreur } = statuts(charge);
assert.equal(erreur, null); assert.equal(erreur, null);
assert.deepEqual([...parId.entries()], [[1, 'ancre'], [2, 'ancre'], [3, 'partiel'], [4, 'mobile']]); 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']]); const NOMS = new Map([[STATUT.ANCRE, 'ancre'], [STATUT.PARTIELLEMENT_FIXE, 'partiel'], [STATUT.MOBILE, 'mobile']]);
assert.ok(parId.size > 0); assert.ok(parId.size > 0);
for (const [id, statut] of parId) assert.equal(statut, NOMS.get(instance.statut[instance.indexDe.get(id)])); 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 { analyser, configurationDepuisCharge } from '../stockage/document.js';
import { lireJournal, reconstruire } from '../stockage/journal.js'; import { lireJournal, reconstruire } from '../stockage/journal.js';
import { SUFFIXES } from '../stockage/noms.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 { VERSION } from '../version.genere.js';
import { COMMANDES } from './commandes.js'; import { COMMANDES } from './commandes.js';
import { creerFacade } from './evenements.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"], ['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"], ['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"], ['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. // 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' } }, 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', '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); 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 ; // 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 // 7. les appartenances des lignes valides se réconcilient avec celles des
// existants, et les doublons se relèvent ; en mode remplacer, qui // 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 // 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 // nom se nomment ; dans chaque mode, les cellules qui s'étendent sur
// plusieurs lignes du texte et portent le séparateur. // 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 // Une ligne refusée ne refuse pas le texte : le reste s'importe, et la ligne
// revient dans le CSV des refus (§ 10.1). // revient dans le CSV des refus (§ 10.1).
import { ETIQUETTES_MAX, lireEtiquettes } from '../stockage/etiquettes.js';
import { associer, reconnaitre } from './colonnes.js'; import { associer, reconnaitre } from './colonnes.js';
import { contientNul } from './encodage.js'; import { contientNul } from './encodage.js';
import { ErreurCsv } from './erreurs.js'; import { ErreurCsv } from './erreurs.js';
import { cleEtiquette, reconcilier as reconcilierEtiquettes } from './etiquettes.js';
import { choisirSeparateur, decouper, enregistrementVide } from './lecture.js'; import { choisirSeparateur, decouper, enregistrementVide } from './lecture.js';
import { cleNormalisee } from './normalisation.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 * @property {boolean|null} exclu null pour une cellule vide ou une
* colonne absente : vide vaut non pour une personne créée, et laisse * colonne absente : vide vaut non pour une personne créée, et laisse
* la valeur d'une personne existante qu'un import met à jour * 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 * @typedef {Object} Apercu
* @property {'ajouter'|'mettreAJour'|'remplacer'} mode le mode pour lequel * @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 * les lignes valides ; ligne : rang de l'enregistrement à partir de
* 1, l'en-tête compris ; champs : chaînes en NFC, blancs de bord * 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 * retirés, vide → null ; brut : les champs tels que découpés
* @property {Array<{ligne: number, code: string, valeur: string|null, brut: string[]}>} refusees * @property {Array<{ligne: number, code: string, valeur: string|null, raison?: string, nombre?: number, brut: string[]}>} refusees
* code : CHAMPS_EN_TROP, NOM_ABSENT, EXCLU_INCONNU ; valeur : la * code : CHAMPS_EN_TROP, NOM_ABSENT, EXCLU_INCONNU,
* valeur d'« exclu » refusée, en NFC sans ses blancs de bord, null * ETIQUETTE_MAL_FORMEE, ETIQUETTES_TROP_NOMBREUSES ; valeur : la
* pour les deux autres codes * 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 * @property {Array<{affichee: string, orthographes: Array<{texte: string, lignes: number[]}>}>} fusions
* chaque groupe d'au moins deux orthographes, existantes comprises * chaque groupe d'au moins deux orthographes, existantes comprises
* hors du mode remplacer, qui porte une ligne valide, rangé par sa * 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 ; * orthographes dans l'ordre de rencontre, la première affichée ;
* lignes : rangs des lignes valides de cette orthographe, vide pour * lignes : rangs des lignes valides de cette orthographe, vide pour
* une orthographe que seuls des participants existants portent * 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 * @property {Array<{cle: string, lignes: number[], existants: number[]}>} doublons
* chaque triplet (nom, prénom, appartenance) normalisé que portent au * chaque triplet (nom, prénom, appartenance) normalisé que portent au
* moins deux personnes, dont une ligne valide, rangé par sa première * 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) : * lignes a la même forme, et l'export l'écrit ainsi (§ 10.2) :
* l'aperçu la nomme sans refuser la ligne * 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 * @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 * sous un refus global, lignes, fusions, fusionsEtiquettes,
* multilignes sont vides, et refusees aussi, sauf sous AUCUNE_LIGNE_VALIDE, dont les lignes * doublons, homonymes et multilignes sont vides, et refusees aussi, sauf sous AUCUNE_LIGNE_VALIDE, dont les lignes
* refusées sont la cause * refusées sont la cause
* @property {number|null} refusGlobalLigne sous GUILLEMET_OUVERT, le rang de * @property {number|null} refusGlobalLigne sous GUILLEMET_OUVERT, le rang de
* l'enregistrement où le guillemet s'ouvre ; sous * l'enregistrement où le guillemet s'ouvre ; sous
@ -156,6 +177,7 @@ function refuserEn(
lignes: [], lignes: [],
refusees: [], refusees: [],
fusions: [], fusions: [],
fusionsEtiquettes: [],
doublons: [], doublons: [],
homonymes: [], homonymes: [],
multilignes: [], multilignes: [],
@ -181,12 +203,38 @@ function valeurDe(brut, rang) {
// sur chaque appartenance, lue ou existante. // sur chaque appartenance, lue ou existante.
const appartenanceNommee = (texte) => typeof texte === 'string' && cleNormalisee(texte) !== ''; 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 // 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 // é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 }, // ou null quand elle n'en nomme aucune, les étiquettes telles que découpées,
// un seul, dans cet ordre : un champ non vide au-delà de l'en-tête, qui dit // ou le motif du refus { code, valeur }, avec raison ou nombre pour les
// une ligne décalée dont aucune valeur n'est sûre ; un nom vide ; une valeur // étiquettes, un seul, dans cet ordre : un champ non vide au-delà de
// d'« exclu » hors de la liste fermée. // 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) { function lireLigne(brut, largeur, colonnes) {
if (!enregistrementVide(brut.slice(largeur))) return { code: 'CHAMPS_EN_TROP', valeur: null }; if (!enregistrementVide(brut.slice(largeur))) return { code: 'CHAMPS_EN_TROP', valeur: null };
const lire = (champ) => valeurDe(brut, colonnes[champ]); const lire = (champ) => valeurDe(brut, colonnes[champ]);
@ -195,6 +243,8 @@ function lireLigne(brut, largeur, colonnes) {
const valeurExclu = lire('exclu'); const valeurExclu = lire('exclu');
const exclu = valeurExclu === null ? null : VALEURS_EXCLU.get(cleNormalisee(valeurExclu)); const exclu = valeurExclu === null ? null : VALEURS_EXCLU.get(cleNormalisee(valeurExclu));
if (exclu === undefined) return { code: 'EXCLU_INCONNU', valeur: 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'); const appartenance = lire('appartenance');
return { return {
champs: { champs: {
@ -205,10 +255,22 @@ function lireLigne(brut, largeur, colonnes) {
titrePressenti: lire('titre_pressenti'), titrePressenti: lire('titre_pressenti'),
notes: lire('notes'), notes: lire('notes'),
exclu, 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é // 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 : // normalisée forment un groupe, que représente la première rencontrée :
// celles des participants existants d'abord, dans l'ordre de existants, // 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 : * remplacer, chaque existant est retiré avant que les lignes s'ajoutent :
* aucune ligne n'en est le doublon, et aucune orthographe existante ne * 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 * 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 {string} texte le texte que rend decoder, ou celui d'un collage
* @param {Object} [options] * @param {Object} [options]
* @param {Array<{id: number, nom: string, prenom: string|null, appartenance: string|null}>} [options.participants] * @param {Array<{id: number, nom: string, prenom: string|null, appartenance: string|null}>} [options.participants]
* les participants existants, dans n'importe quel ordre : l'identifiant * les participants existants, dans n'importe quel ordre : l'identifiant
* ordonne leurs orthographes * 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 * @param {Object<string, number>} [options.choix] { champ: rang } qui tranche
* une ambiguïté de l'en-tête (voir associer) * une ambiguïté de l'en-tête (voir associer)
* @param {'ajouter'|'mettreAJour'|'remplacer'} [options.mode] ajouter par * @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 * @throws {TypeError} quand texte n'est pas une chaîne : des octets passent
* d'abord par decoder ; pour un mode inconnu * 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 (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)}`); 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. // 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]; const brut = enregistrements[indice];
if (enregistrementVide(brut)) continue; if (enregistrementVide(brut)) continue;
const ligne = indice + 1; const ligne = indice + 1;
const lecture = lireLigne(brut, entetes.length, association.colonnes); const { champs: lus, ...refus } = lireLigne(brut, entetes.length, association.colonnes);
if (lecture.champs === undefined) refusees.push({ ligne, code: lecture.code, valeur: lecture.valeur, brut }); if (lus === undefined) refusees.push({ ligne, ...refus, brut });
else valides.push({ ligne, champs: lecture.champs, brut }); else valides.push({ ligne, champs: lus, brut });
} }
if (valides.length === 0) return { ...refuser('AUCUNE_LIGNE_VALIDE', lu), refusees }; if (valides.length === 0) return { ...refuser('AUCUNE_LIGNE_VALIDE', lu), refusees };
const existants = mode === 'remplacer' ? [] : [...participants].sort((a, b) => a.id - b.id); const existants = mode === 'remplacer' ? [] : [...participants].sort((a, b) => a.id - b.id);
const { afficheeDe, fusions } = reconcilier(existants, valides); 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, 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, brut,
})); }));
return { return {
@ -441,6 +515,7 @@ export function apercevoir(texte, { participants = [], choix = {}, mode = 'ajout
lignes, lignes,
refusees, refusees,
fusions, fusions,
fusionsEtiquettes,
doublons: doublonsDe(existants, lignes), doublons: doublonsDe(existants, lignes),
homonymes: mode === 'mettreAJour' ? homonymesDe(participants, lignes) : [], homonymes: mode === 'mettreAJour' ? homonymesDe(participants, lignes) : [],
multilignes: multilignesDe(lignes, separateur), 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) : // É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 // 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, // 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 // les doublons signalés et jamais fusionnés, la colonne des étiquettes, lue
// collé et pour le même texte décodé d'un fichier, et les données d'épreuve // par la découpe de la saisie et réconciliée avec l'événement, le même aperçu
// de test/fixtures/csv/, chacune un CSV tel qu'un tableur l'écrit. Les lettres // pour un texte collé et pour le même texte décodé d'un fichier, les données
// décomposées et les blancs autres que l'espace s'écrivent par leur point de // d'épreuve de test/fixtures/csv/, chacune un CSV tel qu'un tableur l'écrit,
// code. // 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 assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs'; import { readdirSync, readFileSync } from 'node:fs';
import { describe, test } from '../../test/lanceur.js'; import { describe, test } from '../../test/lanceur.js';
import { lireEtiquettes } from '../stockage/etiquettes.js';
import { VALEURS_EXCLU, apercevoir } from './apercu.js'; import { VALEURS_EXCLU, apercevoir } from './apercu.js';
import { contientNul, decoder } from './encodage.js'; import { contientNul, decoder } from './encodage.js';
import { cleNormalisee } from './normalisation.js'; import { cleNormalisee } from './normalisation.js';
const FIXTURES = new URL('../../test/fixtures/csv/', import.meta.url); 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. // Texte d'un CSV : chaque ligne donnée, terminée par CRLF.
const csv = (...lignes) => lignes.map((ligne) => `${ligne}\r\n`).join(''); 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. // Champs d'une ligne valide, dans l'ordre de l'aperçu ; un champ omis vaut null.
const champs = ({ const champs = ({
nom, prenom = null, appartenance = null, courriel = null, titrePressenti = null, notes = null, exclu = null, 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 // Aperçu d'un texte refusé en entier : ce que la lecture a établi avant le
// refus, aucune ligne. // refus, aucune ligne.
@ -60,6 +65,7 @@ const refusGlobal = (
lignes: [], lignes: [],
refusees: [], refusees: [],
fusions: [], fusions: [],
fusionsEtiquettes: [],
doublons: [], doublons: [],
homonymes: [], homonymes: [],
multilignes: [], multilignes: [],
@ -122,6 +128,7 @@ describe("apercevoir : les lignes d'un texte sans défaut (§ 10.1)", () => {
], ],
refusees: [], refusees: [],
fusions: [], fusions: [],
fusionsEtiquettes: [],
doublons: [], doublons: [],
homonymes: [], homonymes: [],
multilignes: [], 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", () => { 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')) const [ligne] = apercevoir(
.lignes; 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( assert.equal(
JSON.stringify(ligne.champs), 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)', () => { 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', () => { test('deux lignes du même triplet normalisé : un doublon, et deux lignes restent deux', () => {
const apercu = apercevoir(csv( const apercu = apercevoir(csv(
@ -1165,6 +1390,7 @@ const DONNEES = [
'derniere_ligne_sans_fin.csv', 'derniere_ligne_sans_fin.csv',
'entete_vide.csv', 'entete_vide.csv',
'espaces_autour.csv', 'espaces_autour.csv',
'etiquettes_virgule_citee.csv',
'ligne_vide_au_milieu.csv', 'ligne_vide_au_milieu.csv',
'marque_crlf.csv', 'marque_crlf.csv',
'utf16le_tabulation.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", () => { test("une colonne d'en-tête vide : non reconnue, sa valeur ignorée, la ligne importée", () => {
const apercu = apercuDe('entete_vide.csv'); const apercu = apercuDe('entete_vide.csv');
assert.deepEqual(apercu.association, { 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, []); 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('titre_pressenti', ['titre_pressenti', 'titre', 'rôle']),
champ('exclu', ['exclu']), champ('exclu', ['exclu']),
champ('notes', ['notes']), 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. */ /** 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 assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js'; import { describe, test } from '../../test/lanceur.js';
import { CHAMPS, ENTETES_DES_CHAMPS, associer, reconnaitre } from './colonnes.js'; import { CHAMPS, ENTETES_DES_CHAMPS, associer, reconnaitre } from './colonnes.js';
import { cleEntete } from './normalisation.js';
describe('CHAMPS', () => { describe('CHAMPS', () => {
test('les sept champs du § 10.1, dans leur ordre, figés', () => { 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']); assert.deepEqual(CHAMPS, [
'nom', 'prenom', 'appartenance', 'courriel', 'titre_pressenti', 'exclu', 'notes', 'etiquettes',
]);
assert.ok(Object.isFrozen(CHAMPS)); 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']], ['titre_pressenti', ['titre_pressenti', 'titre', 'rôle']],
['exclu', ['exclu']], ['exclu', ['exclu']],
['notes', ['notes']], ['notes', ['notes']],
['etiquettes', ['etiquettes', 'étiquette', 'mots-clés']],
], ],
); );
assert.deepEqual(ENTETES_DES_CHAMPS.map(([champ]) => champ), [...CHAMPS]); 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', () => { 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])); 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); 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)', () => { describe('reconnaitre (§ 10.1)', () => {
@ -58,13 +68,21 @@ describe('reconnaitre (§ 10.1)', () => {
for (const champ of CHAMPS) assert.equal(reconnaitre(champ), champ); 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( assert.deepEqual(
['organisation', 'entreprise', 'équipe', 'titre', 'rôle'].map((entete) => reconnaitre(entete)), ['organisation', 'entreprise', 'équipe', 'titre', 'rôle', 'étiquette', 'mots-clés'].map((entete) => reconnaitre(entete)),
['appartenance', 'appartenance', 'appartenance', 'titre_pressenti', 'titre_pressenti'], ['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', () => { test('sans égard à la casse, aux accents ni aux espaces', () => {
const cas = [ const cas = [
['NOM', 'nom'], ['NOM', 'nom'],
@ -101,8 +119,8 @@ describe('reconnaitre (§ 10.1)', () => {
describe('associer : par en-tête, jamais par position (§ 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', () => { 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']), { 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 }, colonnes: { nom: 0, prenom: 1, appartenance: 2, courriel: 3, titre_pressenti: 4, exclu: 5, notes: 6, etiquettes: 7 },
ambigus: [], ambigus: [],
nonReconnues: [], 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)); 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", () => { test("deux colonnes nom : ni l'une ni l'autre", () => {
assert.deepEqual(associer(['Nom', 'prenom', 'NOM']), { assert.deepEqual(associer(['Nom', 'prenom', 'NOM']), {
colonnes: { prenom: 1 }, 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 * Réconcilie les étiquettes des lignes d'un fichier importé (§ 10.1) avec
* l'événement, puis entre elles. Une clé que l'événement porte reçoit la plus * celles de l'événement, puis entre elles. Une clé que l'événement porte
* petite de ses orthographes existantes ; une clé que seules les lignes * reçoit la plus petite de ses orthographes existantes ; une clé que seules
* portent, la plus petite des orthographes lues. Chaque liste est réécrite * les lignes portent, la plus petite des orthographes lues. Chaque liste est
* dans son ordre, une étiquette prenant l'orthographe de sa clé ; une * réécrite dans son ordre, une étiquette prenant l'orthographe de sa clé ;
* orthographe qui en rejoint une autre de la même liste se fond à la place * une orthographe qui en rejoint une autre de la même liste se fond à la
* de la première. Les existantes ne sont jamais réécrites. * 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 * 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à * 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). */ /** Séparateur de l'export : celui qu'un tableur francophone rouvre sans manipulation (§ 10.3). */
export const SEPARATEUR = ';'; 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 MARQUE_ORDRE_OCTETS = '\u{FEFF}';
const FIN_DE_LIGNE = '\r\n'; const FIN_DE_LIGNE = '\r\n';
const GUILLEMET = '"'; const GUILLEMET = '"';
@ -37,6 +45,7 @@ const PROPRIETE_DU_CHAMP = new Map([
['titre_pressenti', 'titrePressenti'], ['titre_pressenti', 'titrePressenti'],
['exclu', 'exclu'], ['exclu', 'exclu'],
['notes', 'notes'], ['notes', 'notes'],
['etiquettes', 'etiquettes'],
]); ]);
// Un champ s'écrit cité (RFC 4180) quand il porte le séparateur, un retour // 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 // 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 // de 0 : exclu en oui ou non, les étiquettes jointes par JOINTURE_ETIQUETTES
// un exclu qui n'est pas un booléen, un nom qui n'est pas une chaîne, un // dans l'ordre de leur liste, vide quand la clé manque, un texte tel quel,
// autre texte ni chaîne ni null. Écrit sans bruit « non » ou vide, un exclu // null vide. Lèvent TypeError un exclu qui n'est pas un booléen, des
// de travers réinviterait au réimport une personne qui s'est désistée ; un // étiquettes présentes qui ne sont pas une liste de textes, un nom qui n'est
// texte d'un autre genre lèverait plus loin, dans l'écriture, sans nommer ni // pas une chaîne, un autre texte ni chaîne ni null. Écrit sans bruit « non »
// la personne ni le champ. // 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) { function cellule(participant, champ, rang) {
const valeur = participant[PROPRIETE_DU_CHAMP.get(champ)]; const valeur = participant[PROPRIETE_DU_CHAMP.get(champ)];
const refuser = (attendu) => { const refuser = (attendu) => {
@ -85,6 +96,11 @@ function cellule(participant, champ, rang) {
if (typeof valeur !== 'boolean') refuser('booléen'); if (typeof valeur !== 'boolean') refuser('booléen');
return valeur ? OUI : NON; 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 (valeur === null && champ !== 'nom') return '';
if (typeof valeur !== 'string') refuser(champ === 'nom' ? 'texte' : 'texte ou null'); if (typeof valeur !== 'string') refuser(champ === 'nom' ? 'texte' : 'texte ou null');
return valeur; 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 * 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 * (§ 10.2) : UTF-8 avec marque, « ; », CRLF ; l'en-tête
* nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes, les champs * nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes;etiquettes,
* de CHAMPS dans leur ordre ; puis un participant par ligne, dans l'ordre de * les champs de CHAMPS dans leur ordre ; puis un participant par ligne, dans
* la liste reçue, que la charge range par identifiant. exclu s'écrit oui ou * l'ordre de la liste reçue, que la charge range par identifiant. exclu
* non, un champ null vide, un champ qui porte « ; », un guillemet ou une fin * s'écrit oui ou non, un champ null vide, la colonne etiquettes toujours,
* de ligne entre guillemets. L'identifiant ne s'écrit pas : le réimport en * 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. * attribue de neufs. La liste reçue n'est pas modifiée.
* *
* @param {import('../stockage/types.js').Participant[]} participants * @param {import('../stockage/types.js').Participant[]} participants
* @returns {Uint8Array} * @returns {Uint8Array}
* @throws {TypeError} pour un exclu qui n'est pas un booléen, un nom qui * @throws {TypeError} pour un exclu qui n'est pas un booléen, des étiquettes
* n'est pas une chaîne, un autre texte ni chaîne ni null * 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) { export function exporterParticipants(participants) {
const lignes = participants.map((participant, rang) => CHAMPS.map((champ) => cellule(participant, champ, rang))); 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) // 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 // É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 —, // l'import lit — marque UTF-8, « ; », CRLF, les huit en-têtes de l'import —,
// les champs cités au besoin, et l'aller-retour qui importe, exporte, // les champs cités au besoin, les étiquettes jointes par « , » et relues par
// réimporte et compare champ par champ, sur chaque donnée d'épreuve de // la découpe de la saisie, et l'aller-retour qui importe, exporte, réimporte
// test/fixtures/csv/ et sur une liste aux valeurs difficiles. L'écriture d'un // 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 // CSV, que l'export partage avec le CSV des refus, s'éprouve contre la
// découpe qui la relit. // découpe qui la relit.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs'; import { readdirSync, readFileSync } from 'node:fs';
import { describe, test } from '../../test/lanceur.js'; import { describe, test } from '../../test/lanceur.js';
import { creerCharge } from '../stockage/document.js'; import { creerCharge } from '../stockage/document.js';
import { SEPARATEURS_ETIQUETTES, lireEtiquettes } from '../stockage/etiquettes.js';
import { VALEURS_EXCLU, apercevoir } from './apercu.js'; import { VALEURS_EXCLU, apercevoir } from './apercu.js';
import { CHAMPS } from './colonnes.js'; import { CHAMPS } from './colonnes.js';
import { decoder } from './encodage.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 { appliquerImport } from './import.js';
import { decouper } from './lecture.js'; import { decouper } from './lecture.js';
const FIXTURES = new URL('../../test/fixtures/csv/', import.meta.url); const FIXTURES = new URL('../../test/fixtures/csv/', import.meta.url);
const EXEMPLES = new URL('../../exemples/', import.meta.url);
const MARQUE = '\u{FEFF}'; const MARQUE = '\u{FEFF}';
@ -35,15 +39,17 @@ const personne = (id, nom, autres = {}) => ({
}); });
// Champs d'un participant, dans l'ordre du fichier d'état. // 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, à // 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 // partir des octets d'un fichier ; l'aperçu ne doit refuser ni le texte ni
// aucune ligne. // aucune ligne, sauf celles que refusesAdmis nomme par leur code.
function importer(octets, nom) { function importer(octets, nom, refusesAdmis = []) {
const apercu = apercevoir(decoder(octets).texte); const apercu = apercevoir(decoder(octets).texte);
assert.equal(apercu.refusGlobal, null, nom); 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 }); const charge = creerCharge({ id: 'evt-essai', nom: "Soirée d'essai", siegesParDefaut: 4, tours: 2 });
return appliquerImport(charge, apercu, 'ajouter').charge.participants; 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)", () => { 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([ const octets = exporterParticipants([
personne(1, 'Ombrelle', { personne(1, 'Ombrelle', {
prenom: 'Iris', appartenance: 'Club des Merles', courriel: 'iris@exemple.test', titrePressenti: 'animation', 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), texteDe(octets),
MARQUE + MARQUE +
csv( csv(
'nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes', 'nom;prenom;appartenance;courriel;titre_pressenti;exclu;notes;etiquettes',
'Ombrelle;Iris;Club des Merles;iris@exemple.test;animation;non;', 'Ombrelle;Iris;Club des Merles;iris@exemple.test;animation;non;;',
'Pervenche;Théo;;;;oui;arrive tard', 'Pervenche;Théo;;;;oui;arrive tard;',
), ),
); );
}); });
test("les en-têtes sont les champs de l'import, CHAMPS, et le séparateur « ; »", () => { test("les en-têtes sont les champs de l'import, CHAMPS, et le séparateur « ; »", () => {
assert.equal(SEPARATEUR, ';'); 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`); 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('oui'), true);
assert.equal(VALEURS_EXCLU.get('non'), false); assert.equal(VALEURS_EXCLU.get('non'), false);
const texte = texteDe(exporterParticipants([personne(1, 'Ombrelle', { exclu: true }), personne(2, 'Pervenche')])); 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', () => { 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 + MARQUE +
csv( csv(
CHAMPS.join(';'), CHAMPS.join(';'),
'Ombrelle;;"Club ""Les Merles"" ; nord";;;non;"premier\r\nsecond"', 'Ombrelle;;"Club ""Les Merles"" ; nord";;;non;"premier\r\nsecond";',
'"""Pervenche""";;;"theo\r@exemple.test";;non;"a\nb"', '"""Pervenche""";;;"theo\r@exemple.test";;non;"a\nb";',
'Sarcelle;;;;;non;virgule, tabulation\tseules', 'Sarcelle;;;;;non;virgule, tabulation\tseules;',
), ),
); );
}); });
test("les lignes suivent l'ordre de la liste reçue", () => { test("les lignes suivent l'ordre de la liste reçue", () => {
const texte = texteDe(exporterParticipants([personne(7, 'Sarcelle'), personne(2, 'Ombrelle')])); 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", () => { 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); const avant = structuredClone(participants);
exporterParticipants(participants); exporterParticipants(participants);
assert.deepEqual(participants, avant); 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, '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, 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, 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", () => { 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(); const donnees = readdirSync(FIXTURES).sort();
assert.ok(donnees.length > 0, "aucune donnée d'épreuve"); assert.ok(donnees.length > 0, "aucune donnée d'épreuve");
assert.ok(donnees.includes('etiquettes_virgule_citee.csv'));
for (const nom of donnees) { for (const nom of donnees) {
const importes = importer(readFileSync(new URL(nom, FIXTURES)), nom); const importes = importer(readFileSync(new URL(nom, FIXTURES)), nom);
assert.ok(importes.length > 0, 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', () => { test('une liste aux valeurs difficiles : guillemets, séparateurs, fins de ligne, formules, accents, exclusions', () => {
const difficiles = [ const difficiles = [
personne(1, 'Ombrelle', { 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(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(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(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); const octets = exporterParticipants(difficiles);
assert.equal(apercevoir(decoder(octets).texte).separateur, ';'); 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 // 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 // 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']); 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 // 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 * @typedef {Object} LigneRefusee
* @property {number} ligne rang de l'enregistrement, en-tête compris * @property {number} ligne rang de l'enregistrement, en-tête compris
* @property {string} code CHAMPS_EN_TROP, NOM_ABSENT ou * @property {string} code CHAMPS_EN_TROP, NOM_ABSENT,
* EXCLU_INCONNU, de l'aperçu ; PLUSIEURS_CORRESPONDENT ou * EXCLU_INCONNU, ETIQUETTE_MAL_FORMEE ou
* DEJA_DESIGNE, de la mise à jour * 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 * @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 * @property {number[]} [participants] refus de la mise à jour : les
* identifiants croissants que la ligne désigne * identifiants croissants que la ligne désigne
* @property {number} [premiereLigne] DEJA_DESIGNE : la ligne qui 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 // Participant créé d'une ligne de l'aperçu, ses clés dans l'ordre du
// fichier d'état : un exclu vide vaut non. // fichier d'état : un exclu vide vaut non ; les étiquettes, rangées, en
const creer = (id, { nom, prenom, appartenance, courriel, titrePressenti, notes, exclu }) => ({ // dernier, la clé absente quand la ligne n'en porte pas (§ 8.9).
id, nom, prenom, appartenance, courriel, titrePressenti, notes, exclu: exclu ?? false, 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 // 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) { function poser(participant, champs) {
let change = false; let change = false;
for (const champ of CHAMPS_LUS) { for (const champ of CHAMPS_LUS) {
@ -230,6 +252,13 @@ function poser(participant, champs) {
change = true; change = true;
} }
} }
if (champs.etiquettes !== null) {
const etiquettes = rangees(champs.etiquettes);
if (!memesEtiquettes(etiquettes, participant.etiquettes ?? [])) {
participant.etiquettes = etiquettes;
change = true;
}
}
return change; 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 * 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 * 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 * 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 * 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 * désigne un participant qu'une ligne précédente désigne déjà, refusée
* DEJA_DESIGNE ; * DEJA_DESIGNE ;
* - 'remplacer' : les participants et leurs réservations sont retirés, * - 'remplacer' : les participants et leurs réservations sont retirés,
* puis chaque ligne valide ajoutée. Tables, titres, propositions et retenu * puis chaque ligne valide ajoutée. Tables, titres, propositions et retenu
* restent (bilanRemplacement). * restent (bilanRemplacement).
* Une personne créée prend les champs de sa ligne, exclu vide valant non ; * Une personne créée prend les champs de sa ligne, exclu vide valant non,
* les identifiants partent de prochainsIds.participant, dans l'ordre du * ses étiquettes rangées par unités de code, absentes quand la ligne n'en
* fichier, et le compteur avance d'autant. La charge et l'aperçu reçus ne * porte pas ; les identifiants partent de prochainsIds.participant, dans
* sont pas modifiés ; la charge rendue n'en partage aucun objet. * 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, * 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 * 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 // 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 // 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 // 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 assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js'; import { describe, test } from '../../test/lanceur.js';
import { serialiser } from '../stockage/canonique.js'; import { serialiser } from '../stockage/canonique.js';
@ -14,6 +16,7 @@ import { VERSION } from '../version.genere.js';
import { apercevoir } from './apercu.js'; import { apercevoir } from './apercu.js';
import { decoder } from './encodage.js'; import { decoder } from './encodage.js';
import { ErreurCsv } from './erreurs.js'; import { ErreurCsv } from './erreurs.js';
import { exporterParticipants } from './export.js';
import { appliquerImport, bilanRemplacement, exporterRefus, pourvoyeursDesTitres } from './import.js'; import { appliquerImport, bilanRemplacement, exporterRefus, pourvoyeursDesTitres } from './import.js';
import { decouper } from './lecture.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 }; return { proposition: id, siegesAttribues, tables, capacites, tours, participants, placement };
} }
// Aperçu d'un texte contre les participants de la charge, dans le mode // Aperçu d'un texte contre les participants et les tables de la charge, dans
// donné, puis import dans ce même mode. // le mode donné, puis import dans ce même mode.
function importer(charge, texte, mode, choix = {}) { 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 // 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)', () => { 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", () => { test("bilanRemplacement compte, avant d'agir, les participants, les réservations, les titres pourvus et les propositions touchées", () => {
const charge = chargeDe({ 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'], ['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'], ['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'], ['rencontrerAnimateurs', 'case des animateurs, sans entrée à l’écran de la génération'],
['priorites', 'ordre des priorités, écrit par son éditeur de la section Génération, non par une entrée'],
]); ]);
// La commande qui écrit un réglage de chaque section, et ses arguments. // La commande qui écrit un réglage de chaque section, et ses arguments.

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 e = await ouvrir();
const { texte } = decoder(await lireLivre(urlGrande)); const { texte } = decoder(await lireLivre(urlGrande));
const enregistrements = texte.split('\r\n'); const enregistrements = texte.split('\r\n');
expect(enregistrements[212]).toMatch(/;non;$/); expect(enregistrements[212]).toMatch(/;non;;$/);
enregistrements[212] = enregistrements[212].replace(/;non;$/, ';peut-être;'); enregistrements[212] = enregistrements[212].replace(/;non;;$/, ';peut-être;;');
await choisir(e, 'grande.csv', enUtf8(enregistrements.join('\r\n'))); await choisir(e, 'grande.csv', enUtf8(enregistrements.join('\r\n')));
const apercu = dans(e, '[data-apercu]'); const apercu = dans(e, '[data-apercu]');
expect(dans(e, '[data-valides] [data-nombre]').textContent).toBe('259'); 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); 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 () => { 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({ const e = await ouvrir({
participants: [ participants: [

View file

@ -6,9 +6,11 @@
// 2). Un fichier choisi par le dialogue de la façade, un fichier déposé // 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 // 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. // 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 // L'aperçu se calcule par apercevoir contre les participants et les
// charge, sous le mode choisi et le choix des colonnes ambiguës : il // tables de la charge — les étiquettes des objectifs donnent leur
// n'écrit rien, et s'ouvre en lecture comme sur un plan bloqué. Seule // 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 // « Importer » écrit, par seance.executer('importerParticipants', …), et
// son refus se pose à côté d'elle ; « Remplacer » passe d'abord par la // son refus se pose à côté d'elle ; « Remplacer » passe d'abord par la
// fenêtre à confirmer, qui porte les quatre comptes de bilanRemplacement, // fenêtre à confirmer, qui porte les quatre comptes de bilanRemplacement,
@ -54,8 +56,9 @@
let enregistre = $state(null); let enregistre = $state(null);
const participants = $derived($seance.charge?.participants ?? []); const participants = $derived($seance.charge?.participants ?? []);
const tables = $derived($seance.charge?.tables ?? []);
const apercu = $derived( 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( const modele = $derived(
source === null ? null : modeleApercu({ encodage: source.encodage, apercu, refusDecodage: source.refusDecodage }), 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 // 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 // src/csv établit : association, lignes, fusions et doublons s'y lisent tels
// quels. // 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 /** La clé du nom de chaque encodage que rend decoder, dans l'ordre de ses
* quatre temps. */ * quatre temps. */
@ -37,6 +37,7 @@ const CLES_DES_CHAMPS = new Map([
['titre_pressenti', 'import.champ.titrePressenti'], ['titre_pressenti', 'import.champ.titrePressenti'],
['exclu', 'import.champ.exclu'], ['exclu', 'import.champ.exclu'],
['notes', 'import.champ.notes'], ['notes', 'import.champ.notes'],
['etiquettes', 'import.champ.etiquettes'],
]); ]);
// La clé de la valeur lue d'« exclu » ; une cellule vide n'en a pas. // La clé de la valeur lue d'« exclu » ; une cellule vide n'en a pas.
@ -186,13 +187,15 @@ function colonnesDe({ entetes, association }) {
} }
// Chaque ligne valide : son rang, puis une cellule par champ, dans l'ordre // 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) { function lignesDe(lignes) {
return lignes.map(({ ligne, champs }) => ({ return lignes.map(({ ligne, champs }) => ({
ligne, ligne,
cellules: CHAMPS.map((champ) => { cellules: CHAMPS.map((champ) => {
const valeur = champs[CHAMPS_LUS.get(champ) ?? champ]; const valeur = champs[CHAMPS_LUS.get(champ) ?? champ];
if (champ === 'exclu') return { champ, texte: null, cle: valeur === null ? null : CLES_D_EXCLU.get(valeur) }; if (champ === 'exclu') return { 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 }; 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 — * 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 * joints par le séparateur de l'aperçu. Sous AUCUN_ENTETE d'un texte non
* blanc, largeur vaut apercu.entetes.length ; sous SEPARATEUR_INTROUVABLE * blanc, largeur vaut apercu.entetes.length ; sous SEPARATEUR_INTROUVABLE
* sans candidat (refusGlobalLigne null : une seule colonne), largeur vaut 1 * sans candidat (refusGlobalLigne null : une seule colonne), largeur vaut 1

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']); 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', () => { 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', 'x1', 'x2'].join(TAB); 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 texte = lignes(rangee('Ombrelle'), rangee('Lacasse'));
const sans = apercevoir(texte); const sans = apercevoir(texte);
assert.equal(sans.refusGlobal, 'AUCUN_ENTETE'); assert.equal(sans.refusGlobal, 'AUCUN_ENTETE');
assert.equal(sans.entetes.length, 9); assert.equal(sans.entetes.length, 10);
const avec = avecEntetesDeLaGrille(texte, sans); const avec = avecEntetesDeLaGrille(texte, sans);
assert.equal(avec.split('\n')[0], [...CHAMPS, '', ''].join(TAB)); assert.equal(avec.split('\n')[0], [...CHAMPS, '', ''].join(TAB));
const lu = apercevoir(avec); const lu = apercevoir(avec);
assert.equal(lu.refusGlobal, null); 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.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(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', () => { 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', () => { 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) }); const { colonnes } = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(texte) });
assert.equal(colonnes.length, 7); assert.equal(colonnes.length, 8);
for (const { cle } of colonnes) { for (const { cle } of colonnes) {
assert.match(cle, /^import\.champ\./); assert.match(cle, /^import\.champ\./);
assert.ok(t(cle).length > 0, cle); 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)', () => { 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( const texte = lignes(
'notes;exclu;nom;prenom', 'notes;exclu;nom;prenom;mots-clés',
'arrive tard;Oui;Ombrelle;Iris', 'arrive tard;Oui;Ombrelle;Iris;',
';;Pervenche;', ';;Pervenche;;finance|Montréal',
';0;Lacasse;Ondine', ';0;Lacasse;Ondine;',
); );
const { lignes: lues } = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(texte) }); const { lignes: lues } = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(texte) });
assert.equal(lues.length, 3); 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]); assert.deepEqual(COLONNES_DES_LIGNES.map(({ champ }) => champ), [...CHAMPS]);
for (const { cle } of COLONNES_DES_LIGNES) assert.ok(t(cle).length > 0, cle); for (const { cle } of COLONNES_DES_LIGNES) assert.ok(t(cle).length > 0, cle);
const [premiere, deuxieme, troisieme] = lues; const [premiere, deuxieme, troisieme] = lues;
assert.deepEqual(premiere.cellules.map(({ texte: valeur }) => valeur), ['Ombrelle', 'Iris', null, null, null, null, 'arrive tard']); 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(premiere.cellules[5].cle, 'import.exclu.oui');
assert.equal(deuxieme.cellules[5].cle, null); assert.equal(deuxieme.cellules[5].cle, null);
assert.equal(troisieme.cellules[5].cle, 'import.exclu.non'); assert.equal(troisieme.cellules[5].cle, 'import.exclu.non');

View file

@ -77,6 +77,10 @@ export const TABLE = new Map([
['communs.remede.changerTours', 'Changer le nombre de tours'], ['communs.remede.changerTours', 'Changer le nombre de tours'],
['communs.remede.modifierReglage', 'Modifier le réglage'], ['communs.remede.modifierReglage', 'Modifier le réglage'],
['communs.remede.changerCibleRedondance', 'Changer la cible de la somme de redondance'], ['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.reserver', 'Réserver la place'],
['communs.remede.retirerReservation', 'Retirer la réservation'], ['communs.remede.retirerReservation', 'Retirer la réservation'],
['communs.remede.deplacerReservation', 'Déplacer 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.titrePressenti', 'titre pressenti'],
['import.champ.exclu', 'exclu'], ['import.champ.exclu', 'exclu'],
['import.champ.notes', 'notes'], ['import.champ.notes', 'notes'],
['import.champ.etiquettes', 'étiquettes'],
['import.exclu.oui', 'oui'], ['import.exclu.oui', 'oui'],
['import.exclu.non', 'non'], ['import.exclu.non', 'non'],
['import.colonne.nonReconnue', 'non reconnue : ignorée'], ['import.colonne.nonReconnue', 'non reconnue : ignorée'],

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)", () => { 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 = { const p = {
id: 1, id: 1,
nom: 'Ombrelle', nom: 'Ombrelle',
@ -309,15 +309,18 @@ describe("l'ordre de la grille est celui de l'export (§ 10.2)", () => {
titrePressenti: 'hôtesse', titrePressenti: 'hôtesse',
notes: 'arrive à neuf heures', notes: 'arrive à neuf heures',
exclu: false, exclu: false,
etiquettes: ['Montréal', 'finance'],
}; };
const { texte } = decoder(exporterParticipants([p])); const { texte } = decoder(exporterParticipants([p]));
const [, ligne] = texte.split('\r\n'); const [entete, ligne] = texte.split('\r\n');
const cellules = ligne.split(';'); const cellules = ligne.split(';');
assert.equal(COLONNES.length, 7); assert.equal(COLONNES.length, 7);
assert.equal(cellules.length, COLONNES.length); assert.equal(cellules.length, COLONNES.length + 1);
COLONNES.forEach((colonne, i) => { COLONNES.forEach((colonne, i) => {
assert.equal(cellules[i], colonne === 'exclu' ? 'non' : p[colonne], colonne); 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', () => { 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 { CATALOGUE } from '../demo/catalogue.js';
import { decouper } from '../csv/lecture.js'; import { decouper } from '../csv/lecture.js';
import { analyser } from '../stockage/document.js'; import { analyser } from '../stockage/document.js';
import { lireEtiquettes } from '../stockage/etiquettes.js';
import { lireJournal } from '../stockage/journal.js'; import { lireJournal } from '../stockage/journal.js';
import { SUFFIXES } from '../stockage/noms.js'; import { SUFFIXES } from '../stockage/noms.js';
import { VERSION } from '../version.genere.js'; import { VERSION } from '../version.genere.js';
@ -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 * 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 * c'est une donnée (§ 14.6, « La traduction ») : noms, prénoms, noms
* affichés, appartenances, courriels, titres et notes des personnes, nom et * affichés, appartenances, courriels, titres, notes et étiquettes des
* base des événements du disque, libellés des titres, libellés figés du * personnes, nom et base des événements du disque, libellés des titres,
* journal, version, provenance, identifiant et instant d'un verrou, cellules * libellés figés du journal, version, provenance, identifiant et instant d'un
* du CSV servi, nom de chaque démonstration du catalogue — le nom que * verrou, cellules du CSV servi, nom de chaque démonstration du catalogue —
* l'événement chargé porte, que l'accueil reçoit comme une donnée — ; un * le nom que l'événement chargé porte, que l'accueil reçoit comme une donnée
* chemin sous une racine du système simulé. * — ; un chemin sous une racine du système simulé. → { valeurs: string[],
* → { valeurs: string[], racines: string[] }, valeurs de la plus longue à la * racines: string[] }, valeurs de la plus longue à la plus courte.
* plus courte.
*/ */
async function donneesConnues(contexte, { cellules = [] } = {}) { async function donneesConnues(contexte, { cellules = [] } = {}) {
const valeurs = new Set([VERSION.affichee, VERSION.windows, PROVENANCE, ...CATALOGUE.map(({ nom }) => nom), ...cellules]); const valeurs = new Set([VERSION.affichee, VERSION.windows, PROVENANCE, ...CATALOGUE.map(({ nom }) => nom), ...cellules]);
const racines = new Set(); const racines = new Set();
const personne = (p) => { 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)); if (p.nom) valeurs.add(nomAffiche(p));
}; };
for (const m of contexte.montages) { 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 /** 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() { async function cellulesCasLimites() {
const { texte } = decoder(await octetsCasLimites()); 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 --------------------------------------------- // --- 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) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Passage de la configuration, forme par identifiants, à l'instance indexée // 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 // que consomme le moteur (§ 4, § 4.5, § 5.2), et conversion d'un plan entre
// formes (§ 8.9). Les formes sont décrites dans types.js. // 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 // 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 — // 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 // 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 // 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 // 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 // pas un objet de quatre booléens, ou dont une case facultative présente
// formes sont les seules que normaliser contrôle par TypeError ; une valeur // n'est pas booléenne, une liste qui n'est pas un tableau, des étiquettes
// qui en sort est une faute de l'appelant, qu'aucune valeur par défaut ne // qui ne sont pas une liste de chaînes, une règle d'objectif ni « ou » ni
// masque ; // « 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 // - 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. // 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 // 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'; import { ErreurConfiguration } from './erreurs.js';
/** /**
@ -54,6 +76,12 @@ export const RESERVE = -1;
/** instance.groupe d'un participant sans appartenance. */ /** instance.groupe d'un participant sans appartenance. */
export const SANS_GROUPE = -1; 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. // Case d'un plan indexé qu'indexerPlan n'a pas encore remplie ; jamais rendue.
const NON_PLACE = -2; const NON_PLACE = -2;
@ -68,8 +96,13 @@ const NOMS_CONTRAINTES = [
'varierAppartenances', 'varierAppartenances',
]; ];
// Valeur citée dans un message : une chaîne s'écrit entre guillemets, pour // Les deux cases facultatives (A16), absentes à leur défaut : la copie ne les
// ne pas se lire comme le nombre qu'elle contient. // 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 // 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 // ne se lise pas comme le nombre qu'elle contient ; toute autre valeur telle
// quelle. // quelle.
@ -102,8 +135,17 @@ function estExclu({ id, exclu }) {
return exclu; return exclu;
} }
// Copie des quatre contraintes : une modification ultérieure de la // Lève TypeError quand valeurs n'est pas une liste de chaînes ; quoi nomme
// configuration n'atteint pas l'instance. // 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) { function copierContraintes(contraintes) {
if (typeof contraintes !== 'object' || contraintes === null) { if (typeof contraintes !== 'object' || contraintes === null) {
throw new TypeError('configuration.contraintes : objet attendu'); throw new TypeError('configuration.contraintes : objet attendu');
@ -115,9 +157,26 @@ function copierContraintes(contraintes) {
} }
copie[nom] = contraintes[nom]; 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; 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 // 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, // 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 // SANS_GROUPE marque l'absence d'appartenance. L'égalité de chaînes fait le
@ -195,11 +254,13 @@ function fixerReservations(reservations, contexte) {
return fixe; return fixe;
} }
// Lève SURRESERVATION quand, à un tour, une table reçoit plus de personnes // Les sièges libres de chaque table à chaque tour, [t × R + r] : sa capacité
// fixées que de sièges (§ 5.9). Le compte porte sur les personnes, lues dans // moins les personnes que fixe y assied. Lève SURRESERVATION quand, à un
// fixe : une réservation répétée ne compte qu'une fois. Les tables se // tour, une table reçoit plus de personnes fixées que de sièges (§ 5.9). Le
// parcourent par index, chacune par tours croissants. // compte porte sur les personnes, lues dans fixe : une réservation répétée ne
function verifierSurreservation(fixe, { N, T, R, capacite, idsTables }) { // 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); const fixees = new Int32Array(T * R);
for (let p = 0; p < N; p += 1) { for (let p = 0; p < N; p += 1) {
for (let r = 0; r < R; r += 1) { for (let r = 0; r < R; r += 1) {
@ -217,8 +278,10 @@ function verifierSurreservation(fixe, { N, T, R, capacite, idsTables }) {
capacite: capacite[t], 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 // 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 }; 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 * 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, * 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), * (participant ou table), RESERVATION_TOUR (tour absent ou hors de 1..R),
* RESERVATION_CONFLIT (une personne à deux tables d'un même tour), * RESERVATION_CONFLIT (une personne à deux tables d'un même tour),
* SURRESERVATION (plus de personnes fixées que de sièges à une table, à un * 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 * tour) ; puis objectifs un à un, tours animés, priorités : OBJECTIF_INCONNU
* chiffre, et la recherche la refuse (§ 5.9). Les détails désignent * {table} (une table absente), OBJECTIF_TOUR {table, tour} (ni nul ni de
* participants et tables par identifiant, les tours par leur numéro à partir * 1..R), OBJECTIF_CONFLIT {table, tour} (deux objectifs d'une table pour la
* de 1, et la réservation fautive par son rang dans * 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. * configuration.reservations, à partir de 0.
* *
* Lève TypeError sur les seules formes qu'énumère l'en-tête du module : * 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, * identifiants de participant et de table, appartenance, exclu, portée,
* contraintes, listes. Une capacité, un nombre de tours ou un tour désigné * contraintes, listes, étiquettes, règle d'un objectif, mode, ordre des
* hors de leur forme lèvent CAPACITE, TOURS ou RESERVATION_TOUR ; une * tours animés. Une capacité, un nombre de tours, un tour désigné, le tour
* réservation qui désigne un participant ou une table absents de la * d'un objectif ou un tour animé hors de leur forme lèvent CAPACITE, TOURS,
* configuration lève RESERVATION_INCONNUE, quelle que soit la forme de la * RESERVATION_TOUR, OBJECTIF_TOUR ou ANIMATION_NON_FIXEE ; une réservation ou
* valeur qui les désigne. * 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 * @param {import('./types.js').Configuration} configuration
* @returns {import('./types.js').Instance} * @returns {import('./types.js').Instance}
@ -287,6 +593,7 @@ export function normaliser(configuration) {
exigerListe(tables, 'tables'); exigerListe(tables, 'tables');
exigerListe(reservations, 'reservations'); exigerListe(reservations, 'reservations');
const contraintes = copierContraintes(configuration.contraintes); const contraintes = copierContraintes(configuration.contraintes);
const modeObjectifs = lireMode(configuration.modeObjectifs);
// connus porte tous les identifiants, exclus compris : un doublon reste un // connus porte tous les identifiants, exclus compris : un doublon reste un
// doublon, et la réservation d'un exclu n'est pas inconnue. // doublon, et la réservation d'un exclu n'est pas inconnue.
@ -300,6 +607,7 @@ export function normaliser(configuration) {
} }
connus.add(participant.id); connus.add(participant.id);
exigerAppartenance(participant); exigerAppartenance(participant);
if (participant.etiquettes !== undefined) exigerEtiquettes(participant.etiquettes, `participant ${participant.id}, étiquettes`);
if (estExclu(participant)) exclus.push(participant.id); if (estExclu(participant)) exclus.push(participant.id);
else presents.push(participant); else presents.push(participant);
} }
@ -329,8 +637,13 @@ export function normaliser(configuration) {
const indexDe = new Map(ids.map((id, p) => [id, p])); const indexDe = new Map(ids.map((id, p) => [id, p]));
const { groupe, groupes } = numeroterGroupes(presents); const { groupe, groupes } = numeroterGroupes(presents);
const fixe = fixerReservations(reservations, { connus, indexDe, indexTableDe, idsTables, N, R }); 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 { 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 { return {
N, N,
@ -350,6 +663,21 @@ export function normaliser(configuration) {
k, k,
n: N - k, n: N - k,
contraintes, 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 { ErreurAnnulee, ErreurConfiguration } from './erreurs.js';
import { import {
LIBRE, LIBRE,
NON_ANIMATEUR,
RESERVE, RESERVE,
SANS_GROUPE, SANS_GROUPE,
SANS_OBJECTIF,
STATUT, STATUT,
exigerPlanIndexe, exigerPlanIndexe,
indexerPlan, indexerPlan,
@ -25,6 +27,7 @@ import {
planDepuisIndex, planDepuisIndex,
replierParPopulation, replierParPopulation,
} from './configuration.js'; } from './configuration.js';
import { CRITERES, PRIORITES_PAR_DEFAUT, verifierPriorites } from './criteres.js';
import { mesurer } from './indicateurs.js'; import { mesurer } from './indicateurs.js';
import { plafondsRealises } from './plafond.js'; import { plafondsRealises } from './plafond.js';
@ -893,3 +896,325 @@ describe('erreurs du moteur', () => {
assert.equal(erreur.name, 'ErreurAnnulee'); 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 * @property {number} ancragesImposantRetours personnes dont les réservations
* imposent un retour * imposent un retour
* @property {{plancher: number, mobilesAuMoins: number}|null} ecartItineraire * @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 { import {
LIBRE, LIBRE,
SANS_GROUPE, SANS_GROUPE,
SANS_OBJECTIF,
STATUT, STATUT,
nombrePlaces, nombrePlaces,
nombrePlacesManquantes, nombrePlacesManquantes,
@ -248,18 +253,35 @@ function avecTablesManquantes(configuration, instance, planchers, chiffrer) {
return lignes; 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 // 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 // déjà 0 ou null, ou qu'aucun R′ ne l'annule. La sonde à R′ tours est la
// réservations de portée « tous », qui couvrent alors ses R′ tours, et retire // configuration ramenée à ses R′ premiers tours. Les R′ se sondent du plus
// celles d'un tour au-delà de R′, exclus compris : normaliser les refuserait // grand au plus petit.
// (RESERVATION_TOUR). Les R′ se sondent du plus grand au plus petit.
function toursSansRedondance(configuration, R, minimale) { function toursSansRedondance(configuration, R, minimale) {
if (minimale === null || minimale === 0) return null; if (minimale === null || minimale === 0) return null;
for (let tours = R - 1; tours >= 1; tours -= 1) { for (let tours = R - 1; tours >= 1; tours -= 1) {
const reservations = configuration.reservations.filter( const sonde = normaliser(surMoinsDeTours(configuration, tours));
({ portee, tour }) => portee !== 'tour' || tour <= tours,
);
const sonde = normaliser({ ...configuration, tours, reservations });
if (redondanceMinimale(sonde, plafondsAPriori(sonde)) === 0) return tours; if (redondanceMinimale(sonde, plafondsAPriori(sonde)) === 0) return tours;
} }
return null; return null;
@ -359,6 +381,64 @@ function ecartItineraire(configuration, instance, aPriori) {
return { plancher, mobilesAuMoins: Math.ceil(siegesToursInferieurs / R) }; 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. // Plus petite et plus grande valeur, null pour une liste vide.
function etendue(valeurs) { function etendue(valeurs) {
if (valeurs.length === 0) return { min: null, max: null }; 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) ; * que mesure tout plan qui honore les réservations (§ 5.4) ;
* ancragesImposantRetours, les personnes qui en portent un au moins. * ancragesImposantRetours, les personnes qui en portent un au moins.
* - ecartItineraire : plancher de l'écart d'itinéraire maximal, ou null. * - 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 {import('./types.js').Configuration} configuration
* @param {{planchersCollisions?: typeof planchersCollisions}} [calcul] * @param {{planchersCollisions?: typeof planchersCollisions}} [calcul]
@ -426,5 +510,6 @@ export function diagnostiquer(configuration, { planchersCollisions: chiffrer = p
retoursImposes: imposes.totalRetoursImposes, retoursImposes: imposes.totalRetoursImposes,
ancragesImposantRetours: imposes.retoursImposes.filter((retours) => retours > 0).length, ancragesImposantRetours: imposes.retoursImposes.filter((retours) => retours > 0).length,
ecartItineraire: ecartItineraire(configuration, instance, aPriori), ecartItineraire: ecartItineraire(configuration, instance, aPriori),
objectifsInevitables: objectifsInevitables(instance),
}; };
} }

View file

@ -1091,6 +1091,7 @@ describe('diagnostic : population vide, forme, refus', () => {
retoursImposes: 0, retoursImposes: 0,
ancragesImposantRetours: 0, ancragesImposantRetours: 0,
ecartItineraire: null, ecartItineraire: null,
objectifsInevitables: { horsObjectif: 0, jamaisServies: 0, horsObjectifMax: 0 },
}; };
// Les mêmes chiffres, séparation active ou non : sans présent, aucune // 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. // 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. // moyenne d'une population sans membre.
// //
// Une seule lecture du plan décide qu'une paire est réunie, collègue ou revue, // 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 // qu'une personne revient, qu'elle est servie par l'objectif de sa table et
// chiffres, et conflitsParTable (conflits.js) le détail par table, si bien // quels animateurs elle rencontre : parcourirTours. mesurer en tire chacun de
// que la somme des violations par table égale le compte de la mesure // 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). // (§ 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 { import {
LIBRE,
RESERVE, RESERVE,
SANS_GROUPE, SANS_GROUPE,
SANS_OBJECTIF,
exigerPlanIndexe, exigerPlanIndexe,
replierParPopulation, replierParPopulation,
} from './configuration.js'; } from './configuration.js';
@ -38,6 +48,24 @@ import {
* @property {Agregat} mobiles mobiles et partiellement fixés (§ 5.4) * @property {Agregat} mobiles mobiles et partiellement fixés (§ 5.4)
* @property {Agregat} ancres * @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 * @typedef {Object} Mesures
* @property {number[]} rencontres |met(p)|, ordre canonique * @property {number[]} rencontres |met(p)|, ordre canonique
* @property {number[]} affilies |F(p)| * @property {number[]} affilies |F(p)|
@ -72,6 +100,8 @@ import {
* @property {Array<{groupe: string, effectif: number, collisionsCumulees: number, * @property {Array<{groupe: string, effectif: number, collisionsCumulees: number,
* pairesDistinctes: number, excedent: number}>} parGroupe * pairesDistinctes: number, excedent: number}>} parGroupe
* ordre de instance.groupes * ordre de instance.groupes
* @property {MesuresObjectifs|null} objectifs
* @property {MesuresAnimateurs|null} animateurs
*/ */
// Plus grand compte que tient un Uint16Array. // 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 * 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 * 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 * 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. * Ne modifie ni l'instance ni le plan ; lève ce que lève exigerPlanIndexe.
* *
* @param {import('./types.js').Instance} instance * @param {import('./types.js').Instance} instance
* @param {ArrayLike<number>} tableDe * @param {ArrayLike<number>} tableDe
* @returns {{parTour: Array<Array<{collegues: number[][], revues: number[][], revenus: number[]}>>, * @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) { export function parcourirTours(instance, tableDe) {
exigerPlanIndexe(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 // 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 // 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 // é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 // personne choisit. Le second parcours de ses tours remet à zéro les
// tables qu'elle a visitées : la personne suivante les trouve nulles. // 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 toursAssis = new Array(N).fill(0);
const retours = 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 revenu = new Uint8Array(N * R);
const visites = new Int32Array(T); const visites = new Int32Array(T);
const reserveeAvant = new Uint8Array(T); const reserveeAvant = new Uint8Array(T);
const rencontre = new Int32Array(A).fill(-1);
for (let p = 0; p < N; p += 1) { for (let p = 0; p < N; p += 1) {
for (let r = 0; r < R; r += 1) { for (let r = 0; r < R; r += 1) {
const t = tableDe[p * R + r]; const t = tableDe[p * R + r];
if (t === RESERVE) continue; if (t === RESERVE) continue;
toursAssis[p] += 1; 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) { if (visites[t] > 0) {
retours[p] += 1; retours[p] += 1;
if (!reservee || reserveeAvant[t] === 0) revenu[p * R + r] = 1; if (!reservee || reserveeAvant[t] === 0) revenu[p * R + r] = 1;
@ -184,7 +240,7 @@ export function parcourirTours(instance, tableDe) {
} }
parTour.push(tables); 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 : // 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); 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 * 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 * 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) { export function mesurer(instance, tableDe) {
const { N, groupe, groupes, statut } = instance; 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 paires = lirePaires(instance, parPaire);
const { collisions, distinctes, choisies, retoursChoisis } = lireTours(instance, parTour); const { collisions, distinctes, choisies, retoursChoisis } = lireTours(instance, parTour);
@ -356,5 +467,7 @@ export function mesurer(instance, tableDe) {
totalRetoursChoisis: somme(retoursChoisis), totalRetoursChoisis: somme(retoursChoisis),
totalRetoursImposes: somme(retoursImposes), totalRetoursImposes: somme(retoursImposes),
parGroupe, parGroupe,
objectifs: lireObjectifs(instance, servis),
animateurs: lireAnimateurs(instance, rencontresAnimateurs),
}; };
} }

View file

@ -8,7 +8,8 @@
// indexerPlan comme un plan enregistré. // indexerPlan comme un plan enregistré.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js'; import { describe, test } from '../../test/lanceur.js';
import { 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'; import { mesurer, parcourirTours } from './indicateurs.js';
const SANS_CONTRAINTE = { const SANS_CONTRAINTE = {
@ -160,6 +161,8 @@ describe('mesurer', () => {
{ groupe: 'B', effectif: 4, collisionsCumulees: 0, pairesDistinctes: 0, excedent: 0 }, { groupe: 'B', effectif: 4, collisionsCumulees: 0, pairesDistinctes: 0, excedent: 0 },
{ groupe: 'C', 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, totalRetoursChoisis: 0,
totalRetoursImposes: 0, totalRetoursImposes: 0,
parGroupe: [], parGroupe: [],
objectifs: null,
animateurs: null,
}); });
}); });
@ -724,6 +729,8 @@ describe('parcourirTours', () => {
parPaire: Uint16Array.of(2, 2, 0, 1, 0, 1), parPaire: Uint16Array.of(2, 2, 0, 1, 0, 1),
toursAssis: [3, 3, 3, 2], toursAssis: [3, 3, 3, 2],
retours: [2, 1, 1, 1], 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), parPaire: new Uint16Array(0),
toursAssis: [], toursAssis: [],
retours: [], retours: [],
servis: [],
rencontresAnimateurs: [],
}); });
}); });
@ -769,3 +778,464 @@ describe('parcourirTours', () => {
assert.deepEqual({ tableDe, fixe: instance.fixe, groupe: instance.groupe }, avant); 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 // 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 // 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 // module cite un type par import('./types.js').Instance. Les valeurs que ces
// formes portent — STATUT, LIBRE, RESERVE, SANS_GROUPE — sont définies et // formes portent — STATUT, LIBRE, RESERVE, SANS_GROUPE, SANS_OBJECTIF,
// exportées par configuration.js, leur seul propriétaire. // 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 * @typedef {Object} Participant
@ -16,6 +21,12 @@
* de chaînes fait le groupe (la * de chaînes fait le groupe (la
* réconciliation est l'affaire du CSV) * réconciliation est l'affaire du CSV)
* @property {boolean} [exclu] absent = false * @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 * @typedef {Object} Table
* @property {number} id entier ≥ 1, unique * @property {number} id entier ≥ 1, unique
@ -28,11 +39,19 @@
* @property {'tous'|'tour'} portee * @property {'tous'|'tour'} portee
* @property {number} [tour] 1..R, requis quand portee === 'tour' * @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 * @typedef {Object} Contraintes
* @property {boolean} separerAppartenances * @property {boolean} separerAppartenances
* @property {boolean} nouveauxVoisins * @property {boolean} nouveauxVoisins
* @property {boolean} nouvelleTable * @property {boolean} nouvelleTable
* @property {boolean} varierAppartenances * @property {boolean} varierAppartenances
* @property {boolean} [respecterObjectifs] absent = vrai (A16)
* @property {boolean} [rencontrerAnimateurs] absent = faux (A16)
* *
* @typedef {Object} Configuration * @typedef {Object} Configuration
* @property {Participant[]} participants * @property {Participant[]} participants
@ -40,6 +59,10 @@
* @property {number} tours R ≥ 1 * @property {number} tours R ≥ 1
* @property {Reservation[]} reservations * @property {Reservation[]} reservations
* @property {Contraintes} contraintes * @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) * @typedef {Object} Plan forme par identifiants (§ 8.9)
* @property {number[]} tables ids de table, dans l'ordre des listes * @property {number[]} tables ids de table, dans l'ordre des listes
@ -67,7 +90,38 @@
* @property {Int32Array} ancresParTable a_t * @property {Int32Array} ancresParTable a_t
* @property {number} k ancrés * @property {number} k ancrés
* @property {number} n N − k * @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 : * Un plan indexé est un Int32Array `tableDe` de longueur N × R :
* tableDe[p * R + r] = index de table, RESERVE (−1) pour la réserve. * 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, * @typedef {{code: string, participant?: number, table?: number, tour?: number,
* detail?: string}} Violation ids, tours numérotés à partir de 1 * 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 * 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 // 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 * 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 * § 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 * toute la ligne. Une violation désigne un calcul faux, sur lequel le profil
* ne se dessine pas. * 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 * Les trois listes suivent l'ordre canonique, celui de instance.ids, et N est
* la longueur de mesures.rencontres. plafondsAPriori vaut null quand le * 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 * 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 * (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, * violation désigne la personne par index, son rang dans l'ordre canonique,
* que instance.ids[index] traduit. Les violations se rangent par index * 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 * 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 * 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 * 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 * manquant, ne passe pas pour lui : il ôterait deux inégalités à la garde
* sans rien signaler. * sans rien signaler.
* *
* @param {{rencontres: ArrayLike<number>}} mesures les mesures d'un plan ; * @param {{rencontres: ArrayLike<number>, toursAssis?: ArrayLike<number>,
* seule la liste rencontres est lue * 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>|null} plafondsAPriori
* @param {ArrayLike<number>} plafondsRealises * @param {ArrayLike<number>} plafondsRealises
* @returns {ViolationIndicateur[]} * @returns {ViolationIndicateur[]}
@ -245,6 +271,16 @@ export function verifierIndicateurs(mesures, plafondsAPriori, plafondsRealises)
const N = rencontres.length; const N = rencontres.length;
exigerLongueur(plafondsRealises, N, 'plafondsRealises', 'liste'); exigerLongueur(plafondsRealises, N, 'plafondsRealises', 'liste');
if (plafondsAPriori !== null) exigerLongueur(plafondsAPriori, N, 'plafondsAPriori', 'liste ou null'); 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 = []; const violations = [];
for (let p = 0; p < N; p += 1) { for (let p = 0; p < N; p += 1) {
const realise = plafondsRealises[p]; 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(aPriori, realise)) violations.push({ code: 'ORDRE_PLAFONDS', index: p });
} }
if (!tient(realise, rencontres[p])) violations.push({ code: 'DEPASSEMENT', 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; return violations;
} }

View file

@ -24,6 +24,8 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js'; import { describe, test } from '../../test/lanceur.js';
import { indexerPlan, normaliser } from './configuration.js'; import { indexerPlan, normaliser } from './configuration.js';
import { mesurer } from './indicateurs.js';
import { plafondsAPriori, plafondsRealises } from './plafond.js';
import { ErreurConfiguration } from './erreurs.js'; import { ErreurConfiguration } from './erreurs.js';
import { verifierIndicateurs, verifierInvariants } from './verification.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, // 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 // 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 // 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, // comptes de la saisie dans l'en-tête — participants, tables, réservations,
// titres — (COMPTES) ; les références (REFERENCE). Les comptes des // titres — (COMPTES) ; les références (REFERENCE). Les comptes des
// propositions et du retenu ne refusent jamais le fichier (§ 8.9, point 3) : // 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 // 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 // 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 // (§ 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 // 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. // réécrit qu'au premier geste, au format courant.
// //
// Les autres fonctions créent une charge ou dérivent d'elle la configuration // 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 // 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. // 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 { HISTORIQUE_PAR_DEFAUT } from '../moteur/recherche.js';
import { ETIQUETTES_MAX, estEtiquetteBienFormee } from './etiquettes.js'; import { ETIQUETTES_MAX, estEtiquetteBienFormee } from './etiquettes.js';
import { ErreurStockage } from './erreurs.js'; import { ErreurStockage } from './erreurs.js';
@ -253,6 +255,26 @@ const OBJECTIF = objet([
// défaut, il est une faute de forme. // défaut, il est une faute de forme.
const horsDuDefaut = (...valeurs) => regle('parmi', { valeurs: Object.freeze(valeurs), optionnel: true }); 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([ const REGLAGES = objet([
['separerAppartenances', BOOLEEN], ['separerAppartenances', BOOLEEN],
['nouveauxVoisins', BOOLEEN], ['nouveauxVoisins', BOOLEEN],
@ -266,12 +288,14 @@ const REGLAGES = objet([
// « et » seulement (regleObjectifs) ; la case des objectifs, décochée // « et » seulement (regleObjectifs) ; la case des objectifs, décochée
// seulement (respecterObjectifs) ; le mode des objectifs hors du plus de // seulement (respecterObjectifs) ; le mode des objectifs hors du plus de
// places servies (modeObjectifs) ; la case des animateurs, cochée // 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 })], ['cibleRedondance', entier(0, Infinity, { optionnel: true })],
['regleObjectifs', horsDuDefaut('et')], ['regleObjectifs', horsDuDefaut('et')],
['respecterObjectifs', horsDuDefaut(false)], ['respecterObjectifs', horsDuDefaut(false)],
['modeObjectifs', horsDuDefaut('chacun', 'minimum')], ['modeObjectifs', horsDuDefaut('chacun', 'minimum')],
['rencontrerAnimateurs', horsDuDefaut(true)], ['rencontrerAnimateurs', horsDuDefaut(true)],
['priorites', PRIORITES],
]); ]);
const PARTICIPANT = objet([ 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 // Comptes de l'en-tête qui portent sur les placements : une proposition ou
// un retenu perdus se recalculent, la saisie non. // un retenu perdus se recalculent, la saisie non.
const COMPTES_DES_PLACEMENTS = new Set(['propositions', 'retenu']); const COMPTES_DES_PLACEMENTS = new Set(['propositions', 'retenu']);
@ -770,6 +825,7 @@ export function analyser(texte) {
examinerIdentifiants(charge, faute); examinerIdentifiants(charge, faute);
examinerTours(charge, faute); examinerTours(charge, faute);
examinerObjectifs(charge, faute); examinerObjectifs(charge, faute);
examinerPriorites(charge, faute);
examinerTitres(charge, faute); examinerTitres(charge, faute);
const manquants = examinerComptes(entete.comptes, charge, plus); const manquants = examinerComptes(entete.comptes, charge, plus);
examinerReferences(charge, plus); examinerReferences(charge, plus);
@ -784,18 +840,23 @@ export function analyser(texte) {
* quatre contraintes des réglages et les deux cases facultatives ; les * quatre contraintes des réglages et les deux cases facultatives ; les
* objectifs des tables, chacun { table, tour, regle, etiquettes }, tour null * 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 * 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 * (regleObjectifs) ; le mode des objectifs ; l'ordre des priorités, tel que
* est absent à son défaut — étiquettes d'une personne qui n'en porte pas, * le rend priorites. Chaque champ nouveau du format 2 est absent à son
* objectifs quand aucune table n'en a, modeObjectifs à « places », * défaut — étiquettes d'une personne qui n'en porte pas, objectifs quand
* respecterObjectifs coché, rencontrerAnimateurs décoché — : la * aucune table n'en a, modeObjectifs à « places », respecterObjectifs coché,
* configuration d'une charge sans eux est celle du format 1. Les tables vont * 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 * 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 * 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). * 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 * 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 * croissants. Les participants et les réservations gardent l'ordre de la
* charge : le rang d'une réservation que nomme une ErreurConfiguration est * 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 * @param {import('./types.js').Charge} charge
* @returns {import('../moteur/types.js').Configuration} * @returns {import('../moteur/types.js').Configuration}
@ -834,6 +895,7 @@ export function configurationDepuisCharge(charge) {
if (objectifs.length > 0) configuration.objectifs = objectifs; if (objectifs.length > 0) configuration.objectifs = objectifs;
const mode = modeObjectifs(charge); const mode = modeObjectifs(charge);
if (mode !== MODE_OBJECTIFS_PAR_DEFAUT) configuration.modeObjectifs = mode; if (mode !== MODE_OBJECTIFS_PAR_DEFAUT) configuration.modeObjectifs = mode;
if (reglages.priorites !== undefined) configuration.priorites = priorites(charge);
return configuration; return configuration;
} }
@ -919,6 +981,23 @@ export function modeObjectifs(charge) {
return charge.reglages.modeObjectifs ?? MODE_OBJECTIFS_PAR_DEFAUT; 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 * 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). * 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 { readFileSync } from 'node:fs';
import { describe, test } from '../../test/lanceur.js'; import { describe, test } from '../../test/lanceur.js';
import { STATUT, normaliser } from '../moteur/configuration.js'; import { STATUT, normaliser } from '../moteur/configuration.js';
import { CRITERES, PRIORITES_PAR_DEFAUT } from '../moteur/criteres.js';
import { ErreurConfiguration } from '../moteur/erreurs.js'; import { ErreurConfiguration } from '../moteur/erreurs.js';
import { HISTORIQUE_PAR_DEFAUT } from '../moteur/recherche.js'; import { HISTORIQUE_PAR_DEFAUT } from '../moteur/recherche.js';
import { texteLivre } from '../demo/livrees.js'; import { texteLivre } from '../demo/livrees.js';
@ -36,6 +37,7 @@ import {
modeObjectifs, modeObjectifs,
premiereFaute, premiereFaute,
premiereFauteEnPlace, premiereFauteEnPlace,
priorites,
regleObjectifs, regleObjectifs,
} from './document.js'; } from './document.js';
import { ErreurStockage } from './erreurs.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 PARTICIPANT = regleDuChamp(CHARGE_SCHEMA, 'participants').element;
const TABLE = regleDuChamp(CHARGE_SCHEMA, 'tables').element; const TABLE = regleDuChamp(CHARGE_SCHEMA, 'tables').element;
assert.deepEqual( 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], ['cibleRedondance', true],
['regleObjectifs', true], ['regleObjectifs', true],
['respecterObjectifs', true], ['respecterObjectifs', true],
['modeObjectifs', true], ['modeObjectifs', true],
['rencontrerAnimateurs', true], ['rencontrerAnimateurs', true],
['priorites', true],
], ],
); );
assert.deepEqual(PARTICIPANT.champs.slice(-2).map(([cle, regle]) => [cle, regle.optionnel]), [['exclu', false], ['etiquettes', 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]); 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) * servies (modeObjectifs)
* @property {true} [rencontrerAnimateurs] facultatif, présent seulement * @property {true} [rencontrerAnimateurs] facultatif, présent seulement
* coché ; absent, décoché (critereCoche) * 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 * @typedef {Object} ProchainsIds entiers de 1 à 2^31 − 1, au-delà de tout
* identifiant attribué ; ils ne reculent * 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