diff --git a/GUIDE-USAGE.md b/GUIDE-USAGE.md index 5c7c4ee..2bebcff 100644 --- a/GUIDE-USAGE.md +++ b/GUIDE-USAGE.md @@ -539,7 +539,11 @@ réglage du poste, pour la séance, automatique par défaut (étape 9). | Nouveaux voisins à chaque tour | Génération | cochée | | Nouvelle table à chaque tour | Génération | cochée | | Varier les appartenances rencontrées | Génération | cochée | +| Respecter les objectifs des tables | Génération | cochée | +| Rencontrer le plus d'animateurs différents | Génération | décochée | | Attribuer les sièges | Génération | décochée | +| Objectifs des tables : chercher d'abord | Génération | le plus de places servies | +| Ordre des priorités | Génération | écart au plafond a priori, puis excédent de collisions, puis collisions cumulées, puis tours hors objectif, puis rencontres répétées et retours, puis animateurs non rencontrés, puis redondance d'appartenance | | Cible de la somme de redondance | Propositions | 1 000 | diff --git a/doc/captures/01b-passer-en-ecriture.png b/doc/captures/01b-passer-en-ecriture.png index 4ae1b53..be23be3 100644 Binary files a/doc/captures/01b-passer-en-ecriture.png and b/doc/captures/01b-passer-en-ecriture.png differ diff --git a/doc/captures/02-import-apercu.png b/doc/captures/02-import-apercu.png index 1cfbadd..24d163c 100644 Binary files a/doc/captures/02-import-apercu.png and b/doc/captures/02-import-apercu.png differ diff --git a/doc/captures/02b-import-cp1252.png b/doc/captures/02b-import-cp1252.png index c5b89fe..e1fee6a 100644 Binary files a/doc/captures/02b-import-cp1252.png and b/doc/captures/02b-import-cp1252.png differ diff --git a/doc/captures/03-import-rapport.png b/doc/captures/03-import-rapport.png index 7441549..cb77841 100644 Binary files a/doc/captures/03-import-rapport.png and b/doc/captures/03-import-rapport.png differ diff --git a/doc/captures/04-grille-participants.png b/doc/captures/04-grille-participants.png index c80b7c7..1e48445 100644 Binary files a/doc/captures/04-grille-participants.png and b/doc/captures/04-grille-participants.png differ diff --git a/doc/captures/05-tables-etat.png b/doc/captures/05-tables-etat.png index b339b85..d971b59 100644 Binary files a/doc/captures/05-tables-etat.png and b/doc/captures/05-tables-etat.png differ diff --git a/doc/captures/06-defaut-consequence.png b/doc/captures/06-defaut-consequence.png index 2df7cda..9d96133 100644 Binary files a/doc/captures/06-defaut-consequence.png and b/doc/captures/06-defaut-consequence.png differ diff --git a/doc/captures/07b-nombre-de-tours.png b/doc/captures/07b-nombre-de-tours.png index bf3a2cd..c21357b 100644 Binary files a/doc/captures/07b-nombre-de-tours.png and b/doc/captures/07b-nombre-de-tours.png differ diff --git a/doc/captures/11-propositions.png b/doc/captures/11-propositions.png index 2b7181c..e437b52 100644 Binary files a/doc/captures/11-propositions.png and b/doc/captures/11-propositions.png differ diff --git a/doc/captures/manifeste.json b/doc/captures/manifeste.json index ec59ade..e007719 100644 --- a/doc/captures/manifeste.json +++ b/doc/captures/manifeste.json @@ -1,20 +1,20 @@ {"captures":[ {"cadre":{"hauteur":700,"largeur":1366},"empreinte":"7e808e7f0a91b7a3793af43ac59537756b3fe7255fef9ec15e01b87c1bc55bf6","etape":"1","etat":null,"facteurEchelle":1,"fichier":"01-premier-ecran","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"6aaf906f36f8d1703168e8d02a7f7fd8da1071f69718bc330e3f88ac1a56ca44"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":15.94,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":6.09,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":9.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":14.82,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":5.57,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-action-texte","ratio":8.45,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":7.6,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":12.07,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":7.21,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Clair","Sombre","À propos","Liste des événements","version","construction de documentation","Importer un événement depuis un fichier…","Créer un événement","Le dossier de l'exécutable n'est pas connu : les événements se rangent dans les Documents.","Créer un événement","Nom de l’événement","Date, facultative (AAAA-MM-JJ)","Créer l’événement","Dossier de travail","C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre","Ouvrir dans l’explorateur","Démonstrations","Chacune se charge comme un nouvel événement, numéroté et ouvert en lecture ; aucun autre événement n’est touché.","Grande démonstration","Grande démonstration sans exception","Petite démonstration","Petite démonstration, conflit inévitable"],"theme":"clair","versionReelle":"2026.10.05.01"}, -{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"aeb4aebc18b37926d2b9e900dc3fdb0930d914b2a49fcc0d66ba229e57808bbb","etape":"1b","etat":"ab468ad775d39cda60057d23f67ba34d787dda85b66c87b5500b41bff1b2b630","facteurEchelle":1,"fichier":"01b-passer-en-ecriture","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"c4a58a564c28cb95ebf638677373c3b8ceecafa45c9483cbd7df9f2bb34d7b40"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":15.94,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":6.09,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":9.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":13.78,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":5.81,"role":"texte","seuil":4.5},{"jeton":"--couleur-refus","ratio":8.34,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-action-texte","ratio":8.45,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":7.6,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":11.07,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":6.72,"role":"texte","seuil":4.5},{"jeton":"--couleur-refus","ratio":8.14,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Lecture","Liste des événements","Clair","Sombre","À propos","Lecture","Rien ne change ; « Modifier » passe en écriture.","Modifier","Lecture","Défaire","État du plan","Brouillon","Proposé","Retenu","Bloqué","Imprimer…","Soirée d’essai","0 participant","0 table · 0 siège","4 tours","Préparer","Import","Participants","Tables et tours","Placer","Plan","Diagnostic","Génération","Propositions","Produire","Badges","Tables et tours","Places par défaut","Nombre de tours","Tables","0 table · 0 siège","Ajouter une table ronde","L'événement est ouvert en lecture : « Modifier » permet de le changer.","Modifier","Ajouter une table carrée","Aucune table : ajoutez-en une, ronde ou carrée."],"theme":"clair","versionReelle":"2026.10.05.01"}, -{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"929bc75db78868c6277d72a20f17752f16d7d646a9ec4e08b4995caee995180c","etape":"2","etat":"4acf3230b0733a35d33c6baf84b7d4f312e98a5b799147dcea6b3bb5d4f082d5","facteurEchelle":1,"fichier":"02-import-apercu","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"c874be30e096c8f31baffc855a5d7dc0f9663d13943e52e587e512a25ada4536"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":15.94,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":6.09,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":9.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":14.16,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":5.81,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-action-texte","ratio":8.45,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":7.6,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":11.07,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":6.72,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Écriture","Chaque geste s’enregistre aussitôt ; « Revenir en lecture » arrête l’écriture.","Revenir en lecture","Écriture","Défaire","État du plan","Brouillon","Proposé","Retenu","Bloqué","Imprimer…","Soirée d’essai","0 participant","1 table · 8 sièges","4 tours","Préparer","Import","Participants","Tables et tours","Placer","Plan","Diagnostic","Génération","Propositions","Produire","Badges","Import","Choisissez un fichier CSV ou déposez-le sur la fenêtre ; une plage collée dans la grille des participants arrive ici. Rien ne s'importe avant l'aperçu.","Choisir un fichier CSV…","Aperçu","Mode de l'import","Ajouter","Mettre à jour","Remplacer","Fichier","participants_cas_limites.csv","Encodage","UTF-8 avec marque","Séparateur","point-virgule","Colonnes","En-tête","Devient","Nom","nom","Prénom","prénom","Organisation","appartenance","Courriel","courriel","Rôle","titre pressenti","Exclu","exclu","Notes","notes","Étiquettes","étiquettes","Téléphone","non reconnue : ignorée","13","lignes valides","Ligne","nom","prénom","appartenance","courriel","titre pressenti","exclu","notes","étiquettes","2","Lanterneau","Basile","Chœur de l’Anse","basile.lanterneau@exemple.test","animation","Finance, Montréal","3","Pommerel","Capucine","Société des Hérons","capucine.pommerel@exemple.test","bénévole, ressources humaines","4","Marbrelle","Gaspard","Cercle des Saules","animation","Finance","5","Sablonnet","Ysaline","Chœur de l’Anse","6","Givrecœur","Anselme","anselme.givrecoeur@exemple.test","7","Tourterel","Héloïse","Cercle des Saules","oui","S’est désistée la veille","8","Fenouillet","Côme","Société des Hérons","végétarien; sans noix, ni arachides","9","Coudraie","Apolline","Chœur de l’Anse","Arrive après le premier tour repart avant le dessert","10","Lanterneau","Basile","Chœur de l’Anse","11","Brunœil","Cercle des Saules","brunoeil@exemple.test","12","Ormelin","Faustine","Société des Hérons","14","Ardoisier","Edgar","Société des Hérons","15","Cressonnier","Bertille","Cercle des Saules","Refus","Lignes refusées","Ligne","Motif","13","Le nom manque.","Information","Orthographes fondues en une appartenance","affichée :","Société des Hérons","Société des Hérons","3","lignes","Societe des herons","1","ligne","Avis","Doublons signalés, jamais fusionnés","lignes","2","10","Importer","Enregistrer les lignes refusées","Fermer l'aperçu"],"theme":"clair","versionReelle":"2026.10.05.01"}, -{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"0fab98ec7a90b614802b93abc62c60c9956074df873bfb8aeaaf14f48fa0ca59","etape":"2","etat":"4acf3230b0733a35d33c6baf84b7d4f312e98a5b799147dcea6b3bb5d4f082d5","facteurEchelle":1,"fichier":"02b-import-cp1252","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"1f15d1bdd53a3754effe4f510242f54ccaf53d1bf6c6e0ff61e99b48fc3b99cf"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":15.94,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":6.09,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":9.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":14.16,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":5.81,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-action-texte","ratio":8.45,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":7.6,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":11.07,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":6.72,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Écriture","Chaque geste s’enregistre aussitôt ; « Revenir en lecture » arrête l’écriture.","Revenir en lecture","Écriture","Défaire","État du plan","Brouillon","Proposé","Retenu","Bloqué","Imprimer…","Soirée d’essai","0 participant","1 table · 8 sièges","4 tours","Préparer","Import","Participants","Tables et tours","Placer","Plan","Diagnostic","Génération","Propositions","Produire","Badges","Import","Choisissez un fichier CSV ou déposez-le sur la fenêtre ; une plage collée dans la grille des participants arrive ici. Rien ne s'importe avant l'aperçu.","Choisir un fichier CSV…","Aperçu","Mode de l'import","Ajouter","Mettre à jour","Remplacer","Fichier","participants_cas_limites_cp1252.csv","Encodage","windows-1252","Séparateur","virgule","Colonnes","En-tête","Devient","Nom","nom","Prénom","prénom","Organisation","appartenance","Courriel","courriel","Rôle","titre pressenti","Exclu","exclu","Notes","notes","Étiquettes","étiquettes","Téléphone","non reconnue : ignorée","13","lignes valides","Ligne","nom","prénom","appartenance","courriel","titre pressenti","exclu","notes","étiquettes","2","Lanterneau","Basile","Chœur de l’Anse","basile.lanterneau@exemple.test","animation","Finance, Montréal","3","Pommerel","Capucine","Société des Hérons","capucine.pommerel@exemple.test","bénévole, ressources humaines","4","Marbrelle","Gaspard","Cercle des Saules","animation","Finance","5","Sablonnet","Ysaline","Chœur de l’Anse","6","Givrecœur","Anselme","anselme.givrecoeur@exemple.test","7","Tourterel","Héloïse","Cercle des Saules","oui","S’est désistée la veille","8","Fenouillet","Côme","Société des Hérons","végétarien; sans noix, ni arachides","9","Coudraie","Apolline","Chœur de l’Anse","Arrive après le premier tour repart avant le dessert","10","Lanterneau","Basile","Chœur de l’Anse","11","Brunœil","Cercle des Saules","brunoeil@exemple.test","12","Ormelin","Faustine","Société des Hérons","14","Ardoisier","Edgar","Société des Hérons","15","Cressonnier","Bertille","Cercle des Saules","Refus","Lignes refusées","Ligne","Motif","13","Le nom manque.","Information","Orthographes fondues en une appartenance","affichée :","Société des Hérons","Société des Hérons","3","lignes","Societe des herons","1","ligne","Avis","Doublons signalés, jamais fusionnés","lignes","2","10","Importer","Enregistrer les lignes refusées","Fermer l'aperçu"],"theme":"clair","versionReelle":"2026.10.05.01"}, -{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"0c176a7b9cf83c8ebbe694766170278bb08c71dd722003ce0a7eadaab71eef69","etape":"2","etat":"bb0e3ac780cce888c1e69ea95d1a96a7b77bbd49e2ce6e815ddbcb8e4bdfc65f","facteurEchelle":1,"fichier":"03-import-rapport","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"17d5fdead2536bd3f32f405c5cf741004855157a29d6d6d111eda3c8a30fff59"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-avis","ratio":6.58,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":15.94,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":6.09,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":9.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":14.16,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":5.81,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-action-texte","ratio":8.45,"role":"texte","seuil":4.5},{"jeton":"--couleur-avis","ratio":8.93,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":7.6,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":11.07,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":6.72,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Écriture","Chaque geste s’enregistre aussitôt ; « Revenir en lecture » arrête l’écriture.","Revenir en lecture","Écriture","Défaire","État du plan","Brouillon","Proposé","Retenu","Bloqué","Imprimer…","Soirée d’essai","13 participants","1 table · 8 sièges","4 tours","Préparer","Import","Participants","Tables et tours","Placer","Plan","Diagnostic","Génération","Propositions","Produire","Badges","Il manque 4 places à chaque tour pour asseoir toutes les personnes : ajouter des tables ou des sièges, ou exclure des personnes, rend la génération possible.","Ouvrir les tables","Import","Choisissez un fichier CSV ou déposez-le sur la fenêtre ; une plage collée dans la grille des participants arrive ici. Rien ne s'importe avant l'aperçu.","Choisir un fichier CSV…","Rapport d'import","13","personnes ajoutées","0","personne mise à jour","0","personne inchangée","0","personne retirée","0","réservation retirée","Fichier","participants_cas_limites.csv","Encodage","UTF-8 avec marque","Séparateur","point-virgule","Colonnes","En-tête","Devient","Nom","nom","Prénom","prénom","Organisation","appartenance","Courriel","courriel","Rôle","titre pressenti","Exclu","exclu","Notes","notes","Étiquettes","étiquettes","Téléphone","non reconnue : ignorée","13","lignes valides","Ligne","nom","prénom","appartenance","courriel","titre pressenti","exclu","notes","étiquettes","2","Lanterneau","Basile","Chœur de l’Anse","basile.lanterneau@exemple.test","animation","Finance, Montréal","3","Pommerel","Capucine","Société des Hérons","capucine.pommerel@exemple.test","bénévole, ressources humaines","4","Marbrelle","Gaspard","Cercle des Saules","animation","Finance","5","Sablonnet","Ysaline","Chœur de l’Anse","6","Givrecœur","Anselme","anselme.givrecoeur@exemple.test","7","Tourterel","Héloïse","Cercle des Saules","oui","S’est désistée la veille","8","Fenouillet","Côme","Société des Hérons","végétarien; sans noix, ni arachides","9","Coudraie","Apolline","Chœur de l’Anse","Arrive après le premier tour repart avant le dessert","10","Lanterneau","Basile","Chœur de l’Anse","11","Brunœil","Cercle des Saules","brunoeil@exemple.test","12","Ormelin","Faustine","Société des Hérons","14","Ardoisier","Edgar","Société des Hérons","15","Cressonnier","Bertille","Cercle des Saules","Refus","Lignes refusées","Ligne","Motif","13","Le nom manque.","Information","Orthographes fondues en une appartenance","affichée :","Société des Hérons","Société des Hérons","3","lignes","Societe des herons","1","ligne","Avis","Doublons signalés, jamais fusionnés","lignes","2","10","Enregistrer les lignes refusées","Fermer le rapport"],"theme":"clair","versionReelle":"2026.10.05.01"}, -{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"ad70127f73495b53749edc09edee1003a37dacd484948ed10913c36db69480dd","etape":"3","etat":"c7c7f05d94c8fcfe6d779172d546d5c73cb2077e9d94ef1348542678c9383c6d","facteurEchelle":1,"fichier":"04-grille-participants","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"ed5490aed00b2a83065cda30c929885aca39274272eca225fa64aebc8c4d6e31"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-avis","ratio":6.58,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":15.94,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":6.09,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":9.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":13.81,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":5.81,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-action-texte","ratio":8.45,"role":"texte","seuil":4.5},{"jeton":"--couleur-avis","ratio":8.93,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":7.6,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":11.07,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":6.72,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Écriture","Chaque geste s’enregistre aussitôt ; « Revenir en lecture » arrête l’écriture.","Revenir en lecture","Écriture","Défaire","État du plan","Brouillon","Proposé","Retenu","Bloqué","Imprimer…","Soirée d’essai","13 participants","1 table · 8 sièges","4 tours","Préparer","Import","Participants","Tables et tours","Placer","Plan","Diagnostic","Génération","Propositions","Produire","Badges","Il manque 4 places à chaque tour pour asseoir toutes les personnes : ajouter des tables ou des sièges, ou exclure des personnes, rend la génération possible.","Ouvrir les tables","Participants","13 personnes · 12 à placer · 1 exclue · 3 appartenances","Exporter la liste","Ajouter une personne","Nom","Prénom","Appartenance","Courriel","Titre pressenti","Notes","Ajouter","Filtrer","Grouper par","Aucun groupement","Nom","Prénom","Appartenance","Courriel","Titre pressenti","Exclusion","Notes","Statut","13 participants","Cercle des Saules · 4","Chœur de l’Anse · 4","Société des Hérons · 4","Nom","(décroissant)","Prénom","Appartenance","Courriel","Titre pressenti","Exclusion","Notes","Actions","Tourterel","Héloïse","Cercle des Saules","exclue","S’est désistée la veille","Fiche","Voir sur le plan","Réintégrer","Supprimer","Sablonnet","Ysaline","Chœur de l’Anse","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Pommerel","Capucine","Société des Hérons","capucine.pommerel@exemple.test","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Ormelin","Faustine","Société des Hérons","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Marbrelle","Gaspard","Cercle des Saules","ondine@exemple.test","animation","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Lanterneau","Basile","Chœur de l’Anse","basile.lanterneau@exemple.test","animation","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Lanterneau","Basile","Chœur de l’Anse","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Givrecœur","Anselme","anselme.givrecoeur@exemple.test","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Fenouillet","Côme","Société des Hérons","présente","végétarien; sans noix, ni arachides","Fiche","Voir sur le plan","Exclure","Supprimer","Cressonnier","Bertille","Cercle des Saules","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Coudraie","Apolline","Chœur de l’Anse","présente","Arrive après le premier tour repart avant le dessert","Fiche","Voir sur le plan","Exclure","Supprimer","Brunœil","Cercle des Saules","brunoeil@exemple.test","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Ardoisier","Edgar","Société des Hérons","présente","Fiche","Voir sur le plan","Exclure","Supprimer"],"theme":"clair","versionReelle":"2026.10.05.01"}, -{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"7a426ff7edf777dbf7ef060fd7d88954029db2a6406299a989c131f37ef49ecc","etape":"4","etat":"4a66e742567978c38f82db56344c20c5659f23c321c9353722f0fb25a7b8d019","facteurEchelle":1,"fichier":"05-tables-etat","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"484f30a9aa00e3130832590c9a28ff7cdf385eaf33b9916300e47da73334caf6"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":15.94,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":6.09,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":9.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":14.1,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":5.81,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-action-texte","ratio":8.45,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":7.6,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":11.07,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":6.72,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Écriture","Chaque geste s’enregistre aussitôt ; « Revenir en lecture » arrête l’écriture.","Revenir en lecture","Écriture","Défaire","État du plan","Brouillon","Proposé","Retenu","Bloqué","Imprimer…","Soirée d’essai","13 participants","2 tables · 16 sièges","4 tours","Préparer","Import","Participants","Tables et tours","Placer","Plan","Diagnostic","Génération","Propositions","Produire","Badges","Tables et tours","Places par défaut","Nombre de tours","Tables","2 tables · 16 sièges","Ajouter une table ronde","Ajouter une table carrée","Table","1","Ronde","Suit le défaut","Places","Supprimer","Table","2","Ronde","Surchargée","Places","Réaligner","Supprimer"],"theme":"clair","versionReelle":"2026.10.05.01"}, -{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"063d42cbcbd85652b18d97cd047bdcdb56de8dd4c90a3f94f2b8b89c5444749e","etape":"4","etat":"4a66e742567978c38f82db56344c20c5659f23c321c9353722f0fb25a7b8d019","facteurEchelle":1,"fichier":"06-defaut-consequence","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"5799dc9d61b9b754dfee488641800ec40574b585ede1ab9e76c5755c72b2529f"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":15.94,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":6.09,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":9.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":14.16,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":5.81,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-action-texte","ratio":8.45,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":7.6,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":11.07,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":6.72,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Écriture","Chaque geste s’enregistre aussitôt ; « Revenir en lecture » arrête l’écriture.","Revenir en lecture","Écriture","Défaire","État du plan","Brouillon","Proposé","Retenu","Bloqué","Imprimer…","Soirée d’essai","13 participants","2 tables · 16 sièges","4 tours","Préparer","Import","Participants","Tables et tours","Placer","Plan","Diagnostic","Génération","Propositions","Produire","Badges","Tables et tours","Places par défaut","1 table suit le défaut et passe de 8 à 10 places ; 1 table surchargée (8) ne change pas.","Total : 16 → 18 sièges pour 12 participants.","Appliquer","Appliquer et réaligner la surchargée","Garder le défaut actuel","Nombre de tours","Tables","2 tables · 16 sièges","Ajouter une table ronde","Ajouter une table carrée","Table","1","Ronde","Suit le défaut","Places","Supprimer","Table","2","Ronde","Surchargée","Places","Réaligner","Supprimer"],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"b1cb20af86a4b974a88b224fe819ff472f1441fe32c83810844fc63fdc14b9b7","etape":"1b","etat":"ab468ad775d39cda60057d23f67ba34d787dda85b66c87b5500b41bff1b2b630","facteurEchelle":1,"fichier":"01b-passer-en-ecriture","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"f8eefcf7d3a4bf9c7b031e1197581fd7228a9e8da8ec01fbe24a591724bd9a15"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":15.94,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":6.09,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":9.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":13.78,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":5.81,"role":"texte","seuil":4.5},{"jeton":"--couleur-refus","ratio":8.34,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-action-texte","ratio":8.45,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":7.6,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":11.07,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":6.72,"role":"texte","seuil":4.5},{"jeton":"--couleur-refus","ratio":8.14,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Lecture","Liste des événements","Clair","Sombre","À propos","Lecture","Rien ne change ; « Modifier » passe en écriture.","Modifier","Lecture","Défaire","État du plan","Brouillon","Proposé","Retenu","Bloqué","Imprimer…","Soirée d’essai","0 participant","0 table · 0 siège","4 tours","Préparer","Import","Participants","Tables et tours","Placer","Plan","Diagnostic","Génération","Propositions","Produire","Badges","Tables et tours","Places par défaut","Nombre de tours","Règle par défaut des objectifs","au moins une (OU)","toutes (ET)","Tables","0 table · 0 siège","Ajouter une table ronde","L'événement est ouvert en lecture : « Modifier » permet de le changer.","Modifier","Ajouter une table carrée","Exporter les objectifs","Aucune table : ajoutez-en une, ronde ou carrée."],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"449987da2e98b09834cdc7c9c6e430a9ecbb929c326ddb96b6f6e17ff5383283","etape":"2","etat":"4acf3230b0733a35d33c6baf84b7d4f312e98a5b799147dcea6b3bb5d4f082d5","facteurEchelle":1,"fichier":"02-import-apercu","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"63691d27a86d871da56922524ff42eca058c1d3f39e55aa1b959e063590bd5b3"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":15.94,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":6.09,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":9.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":14.16,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":5.81,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-action-texte","ratio":8.45,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":7.6,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":11.07,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":6.72,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Écriture","Chaque geste s’enregistre aussitôt ; « Revenir en lecture » arrête l’écriture.","Revenir en lecture","Écriture","Défaire","État du plan","Brouillon","Proposé","Retenu","Bloqué","Imprimer…","Soirée d’essai","0 participant","1 table · 8 sièges","4 tours","Préparer","Import","Participants","Tables et tours","Placer","Plan","Diagnostic","Génération","Propositions","Produire","Badges","Import","Choisissez un fichier CSV ou déposez-le sur la fenêtre ; une plage collée dans la grille des participants arrive ici. Rien ne s'importe avant l'aperçu.","Choisir un fichier CSV…","Contenu du fichier","Participants","Objectifs de table","Aperçu","Mode de l'import","Ajouter","Mettre à jour","Remplacer","Fichier","participants_cas_limites.csv","Encodage","UTF-8 avec marque","Séparateur","point-virgule","Colonnes","En-tête","Devient","Nom","nom","Prénom","prénom","Organisation","appartenance","Courriel","courriel","Rôle","titre pressenti","Exclu","exclu","Notes","notes","Étiquettes","étiquettes","Téléphone","non reconnue : ignorée","13","lignes valides","Ligne","nom","prénom","appartenance","courriel","titre pressenti","exclu","notes","étiquettes","2","Lanterneau","Basile","Chœur de l’Anse","basile.lanterneau@exemple.test","animation","Finance","Montréal","3","Pommerel","Capucine","Société des Hérons","capucine.pommerel@exemple.test","bénévole","ressources humaines","4","Marbrelle","Gaspard","Cercle des Saules","animation","Finance","5","Sablonnet","Ysaline","Chœur de l’Anse","6","Givrecœur","Anselme","anselme.givrecoeur@exemple.test","7","Tourterel","Héloïse","Cercle des Saules","oui","S’est désistée la veille","8","Fenouillet","Côme","Société des Hérons","végétarien; sans noix, ni arachides","9","Coudraie","Apolline","Chœur de l’Anse","Arrive après le premier tour repart avant le dessert","10","Lanterneau","Basile","Chœur de l’Anse","11","Brunœil","Cercle des Saules","brunoeil@exemple.test","12","Ormelin","Faustine","Société des Hérons","14","Ardoisier","Edgar","Société des Hérons","15","Cressonnier","Bertille","Cercle des Saules","Refus","Lignes refusées","Ligne","Motif","13","Le nom manque.","Information","Orthographes fondues en une appartenance","affichée :","Société des Hérons","Société des Hérons","3","lignes","Societe des herons","1","ligne","Information","Orthographes fondues en une étiquette","retenue :","Finance","orthographes :","Finance","finance","1","ligne réécrite","Avis","Doublons signalés, jamais fusionnés","lignes","2","10","Importer","Enregistrer les lignes refusées","Fermer l'aperçu"],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"3627114c306d2fd47bff249468aec410a48c644245ecb1f85bb60826c5b80acf","etape":"2","etat":"4acf3230b0733a35d33c6baf84b7d4f312e98a5b799147dcea6b3bb5d4f082d5","facteurEchelle":1,"fichier":"02b-import-cp1252","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"173368cf8cf2bf154e6ddba1fd46d7ae149dd6e436e2bd000a2e7630042112c5"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":15.94,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":6.09,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":9.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":14.16,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":5.81,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-action-texte","ratio":8.45,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":7.6,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":11.07,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":6.72,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Écriture","Chaque geste s’enregistre aussitôt ; « Revenir en lecture » arrête l’écriture.","Revenir en lecture","Écriture","Défaire","État du plan","Brouillon","Proposé","Retenu","Bloqué","Imprimer…","Soirée d’essai","0 participant","1 table · 8 sièges","4 tours","Préparer","Import","Participants","Tables et tours","Placer","Plan","Diagnostic","Génération","Propositions","Produire","Badges","Import","Choisissez un fichier CSV ou déposez-le sur la fenêtre ; une plage collée dans la grille des participants arrive ici. Rien ne s'importe avant l'aperçu.","Choisir un fichier CSV…","Contenu du fichier","Participants","Objectifs de table","Aperçu","Mode de l'import","Ajouter","Mettre à jour","Remplacer","Fichier","participants_cas_limites_cp1252.csv","Encodage","windows-1252","Séparateur","virgule","Colonnes","En-tête","Devient","Nom","nom","Prénom","prénom","Organisation","appartenance","Courriel","courriel","Rôle","titre pressenti","Exclu","exclu","Notes","notes","Étiquettes","étiquettes","Téléphone","non reconnue : ignorée","13","lignes valides","Ligne","nom","prénom","appartenance","courriel","titre pressenti","exclu","notes","étiquettes","2","Lanterneau","Basile","Chœur de l’Anse","basile.lanterneau@exemple.test","animation","Finance","Montréal","3","Pommerel","Capucine","Société des Hérons","capucine.pommerel@exemple.test","bénévole","ressources humaines","4","Marbrelle","Gaspard","Cercle des Saules","animation","Finance","5","Sablonnet","Ysaline","Chœur de l’Anse","6","Givrecœur","Anselme","anselme.givrecoeur@exemple.test","7","Tourterel","Héloïse","Cercle des Saules","oui","S’est désistée la veille","8","Fenouillet","Côme","Société des Hérons","végétarien; sans noix, ni arachides","9","Coudraie","Apolline","Chœur de l’Anse","Arrive après le premier tour repart avant le dessert","10","Lanterneau","Basile","Chœur de l’Anse","11","Brunœil","Cercle des Saules","brunoeil@exemple.test","12","Ormelin","Faustine","Société des Hérons","14","Ardoisier","Edgar","Société des Hérons","15","Cressonnier","Bertille","Cercle des Saules","Refus","Lignes refusées","Ligne","Motif","13","Le nom manque.","Information","Orthographes fondues en une appartenance","affichée :","Société des Hérons","Société des Hérons","3","lignes","Societe des herons","1","ligne","Information","Orthographes fondues en une étiquette","retenue :","Finance","orthographes :","Finance","finance","1","ligne réécrite","Avis","Doublons signalés, jamais fusionnés","lignes","2","10","Importer","Enregistrer les lignes refusées","Fermer l'aperçu"],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"58a56b83f56dd4bf4a6de25f864e4bad702837640873a1bca11c9293f5dd4992","etape":"2","etat":"bb0e3ac780cce888c1e69ea95d1a96a7b77bbd49e2ce6e815ddbcb8e4bdfc65f","facteurEchelle":1,"fichier":"03-import-rapport","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"59a799f28e555e3571f62ea55379902f62f225c5ed8c9ef926d6a05611fc0698"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-avis","ratio":6.58,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":15.94,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":6.09,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":9.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":14.16,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":5.81,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-action-texte","ratio":8.45,"role":"texte","seuil":4.5},{"jeton":"--couleur-avis","ratio":8.93,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":7.6,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":11.07,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":6.72,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Écriture","Chaque geste s’enregistre aussitôt ; « Revenir en lecture » arrête l’écriture.","Revenir en lecture","Écriture","Défaire","État du plan","Brouillon","Proposé","Retenu","Bloqué","Imprimer…","Soirée d’essai","13 participants","1 table · 8 sièges","4 tours","Préparer","Import","Participants","Tables et tours","Placer","Plan","Diagnostic","Génération","Propositions","Produire","Badges","Il manque 4 places à chaque tour pour asseoir toutes les personnes : ajouter des tables ou des sièges, ou exclure des personnes, rend la génération possible.","Ouvrir les tables","Import","Choisissez un fichier CSV ou déposez-le sur la fenêtre ; une plage collée dans la grille des participants arrive ici. Rien ne s'importe avant l'aperçu.","Choisir un fichier CSV…","Contenu du fichier","Participants","Objectifs de table","Rapport d'import","13","personnes ajoutées","0","personne mise à jour","0","personne inchangée","0","personne retirée","0","réservation retirée","Fichier","participants_cas_limites.csv","Encodage","UTF-8 avec marque","Séparateur","point-virgule","Colonnes","En-tête","Devient","Nom","nom","Prénom","prénom","Organisation","appartenance","Courriel","courriel","Rôle","titre pressenti","Exclu","exclu","Notes","notes","Étiquettes","étiquettes","Téléphone","non reconnue : ignorée","13","lignes valides","Ligne","nom","prénom","appartenance","courriel","titre pressenti","exclu","notes","étiquettes","2","Lanterneau","Basile","Chœur de l’Anse","basile.lanterneau@exemple.test","animation","Finance","Montréal","3","Pommerel","Capucine","Société des Hérons","capucine.pommerel@exemple.test","bénévole","ressources humaines","4","Marbrelle","Gaspard","Cercle des Saules","animation","Finance","5","Sablonnet","Ysaline","Chœur de l’Anse","6","Givrecœur","Anselme","anselme.givrecoeur@exemple.test","7","Tourterel","Héloïse","Cercle des Saules","oui","S’est désistée la veille","8","Fenouillet","Côme","Société des Hérons","végétarien; sans noix, ni arachides","9","Coudraie","Apolline","Chœur de l’Anse","Arrive après le premier tour repart avant le dessert","10","Lanterneau","Basile","Chœur de l’Anse","11","Brunœil","Cercle des Saules","brunoeil@exemple.test","12","Ormelin","Faustine","Société des Hérons","14","Ardoisier","Edgar","Société des Hérons","15","Cressonnier","Bertille","Cercle des Saules","Refus","Lignes refusées","Ligne","Motif","13","Le nom manque.","Information","Orthographes fondues en une appartenance","affichée :","Société des Hérons","Société des Hérons","3","lignes","Societe des herons","1","ligne","Information","Orthographes fondues en une étiquette","retenue :","Finance","orthographes :","Finance","finance","1","ligne réécrite","Avis","Doublons signalés, jamais fusionnés","lignes","2","10","Enregistrer les lignes refusées","Fermer le rapport"],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"2bd426f6ae2820694168b8af62ef8241d6d81e72859318e004ddbfb828d4ff18","etape":"3","etat":"c7c7f05d94c8fcfe6d779172d546d5c73cb2077e9d94ef1348542678c9383c6d","facteurEchelle":1,"fichier":"04-grille-participants","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"374cf28f7d60cb013b481367d079fd67511696aa5d1f42eb3929134144664b79"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-avis","ratio":6.58,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":15.94,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":6.09,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":9.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":13.81,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":5.81,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-action-texte","ratio":8.45,"role":"texte","seuil":4.5},{"jeton":"--couleur-avis","ratio":8.93,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":7.6,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":11.07,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":6.72,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Écriture","Chaque geste s’enregistre aussitôt ; « Revenir en lecture » arrête l’écriture.","Revenir en lecture","Écriture","Défaire","État du plan","Brouillon","Proposé","Retenu","Bloqué","Imprimer…","Soirée d’essai","13 participants","1 table · 8 sièges","4 tours","Préparer","Import","Participants","Tables et tours","Placer","Plan","Diagnostic","Génération","Propositions","Produire","Badges","Il manque 4 places à chaque tour pour asseoir toutes les personnes : ajouter des tables ou des sièges, ou exclure des personnes, rend la génération possible.","Ouvrir les tables","Participants","13 personnes · 12 à placer · 1 exclue · 3 appartenances","Exporter la liste","Ajouter une personne","Nom","Prénom","Appartenance","Courriel","Titre pressenti","Notes","Ajouter","Filtrer","Grouper par","Aucun groupement","Nom","Prénom","Appartenance","Courriel","Titre pressenti","Exclusion","Notes","Statut","13 participants","Cercle des Saules · 4","Chœur de l’Anse · 4","Société des Hérons · 4","bénévole · 1","Finance · 2","Montréal · 1","ressources humaines · 1","Nom","(décroissant)","Prénom","Appartenance","Courriel","Titre pressenti","Exclusion","Notes","Actions","Tourterel","Héloïse","Cercle des Saules","exclue","S’est désistée la veille","Fiche","Voir sur le plan","Réintégrer","Supprimer","Sablonnet","Ysaline","Chœur de l’Anse","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Pommerel","Capucine","Société des Hérons","capucine.pommerel@exemple.test","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Ormelin","Faustine","Société des Hérons","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Marbrelle","Gaspard","Cercle des Saules","ondine@exemple.test","animation","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Lanterneau","Basile","Chœur de l’Anse","basile.lanterneau@exemple.test","animation","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Lanterneau","Basile","Chœur de l’Anse","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Givrecœur","Anselme","anselme.givrecoeur@exemple.test","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Fenouillet","Côme","Société des Hérons","présente","végétarien; sans noix, ni arachides","Fiche","Voir sur le plan","Exclure","Supprimer","Cressonnier","Bertille","Cercle des Saules","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Coudraie","Apolline","Chœur de l’Anse","présente","Arrive après le premier tour repart avant le dessert","Fiche","Voir sur le plan","Exclure","Supprimer","Brunœil","Cercle des Saules","brunoeil@exemple.test","présente","Fiche","Voir sur le plan","Exclure","Supprimer","Ardoisier","Edgar","Société des Hérons","présente","Fiche","Voir sur le plan","Exclure","Supprimer"],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"f8c9b8b09a2bca26dd825b461ed7ae2a13b38320371b10a67d10bbf4b2ca3d03","etape":"4","etat":"4a66e742567978c38f82db56344c20c5659f23c321c9353722f0fb25a7b8d019","facteurEchelle":1,"fichier":"05-tables-etat","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"b5e53613327467ae7600cdb222ac18078ece3d70f01f189973cba1fa385693a0"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":15.94,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":6.09,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":9.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":14.1,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":5.81,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-action-texte","ratio":8.45,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":7.6,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":11.07,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":6.72,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Écriture","Chaque geste s’enregistre aussitôt ; « Revenir en lecture » arrête l’écriture.","Revenir en lecture","Écriture","Défaire","État du plan","Brouillon","Proposé","Retenu","Bloqué","Imprimer…","Soirée d’essai","13 participants","2 tables · 16 sièges","4 tours","Préparer","Import","Participants","Tables et tours","Placer","Plan","Diagnostic","Génération","Propositions","Produire","Badges","Tables et tours","Places par défaut","Nombre de tours","Règle par défaut des objectifs","au moins une (OU)","toutes (ET)","Tables","2 tables · 16 sièges","Ajouter une table ronde","Ajouter une table carrée","Exporter les objectifs","Table","1","Ronde","Suit le défaut","Places","Supprimer","Objectif de la soirée","Règle de l'objectif","selon le réglage général (au moins une)","au moins une (OU)","toutes (ET)","Objectifs par tour","Table","2","Ronde","Surchargée","Places","Réaligner","Supprimer","Objectif de la soirée","Règle de l'objectif","selon le réglage général (au moins une)","au moins une (OU)","toutes (ET)","Objectifs par tour"],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"87d1b702e30c34fc6c6e5a4430289cdb290e8d24a4a80e2b19b4de60d0e7bbce","etape":"4","etat":"4a66e742567978c38f82db56344c20c5659f23c321c9353722f0fb25a7b8d019","facteurEchelle":1,"fichier":"06-defaut-consequence","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"40a75346ff6ced3c263d9eb04f1e84d80153eb468fa6149e5ca77565f2171970"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":15.94,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":6.09,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":9.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":14.16,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":5.81,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-action-texte","ratio":8.45,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":7.6,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":11.07,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":6.72,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Écriture","Chaque geste s’enregistre aussitôt ; « Revenir en lecture » arrête l’écriture.","Revenir en lecture","Écriture","Défaire","État du plan","Brouillon","Proposé","Retenu","Bloqué","Imprimer…","Soirée d’essai","13 participants","2 tables · 16 sièges","4 tours","Préparer","Import","Participants","Tables et tours","Placer","Plan","Diagnostic","Génération","Propositions","Produire","Badges","Tables et tours","Places par défaut","1 table suit le défaut et passe de 8 à 10 places ; 1 table surchargée (8) ne change pas.","Total : 16 → 18 sièges pour 12 participants.","Appliquer","Appliquer et réaligner la surchargée","Garder le défaut actuel","Nombre de tours","Règle par défaut des objectifs","au moins une (OU)","toutes (ET)","Tables","2 tables · 16 sièges","Ajouter une table ronde","Ajouter une table carrée","Exporter les objectifs","Table","1","Ronde","Suit le défaut","Places","Supprimer","Objectif de la soirée","Règle de l'objectif","selon le réglage général (au moins une)","au moins une (OU)","toutes (ET)","Objectifs par tour","Table","2","Ronde","Surchargée","Places","Réaligner","Supprimer","Objectif de la soirée","Règle de l'objectif","selon le réglage général (au moins une)","au moins une (OU)","toutes (ET)","Objectifs par tour"],"theme":"clair","versionReelle":"2026.10.05.01"}, {"cadre":{"hauteur":700,"largeur":1366},"empreinte":"95bbe395796491c7520c0580800ef9811860d0ab98d8f80d4b79e401bb129cec","etape":"4","etat":"7896d20e0476f9a43ce249f3bee865b627a32343863633b9849f7e8c78543678","facteurEchelle":1,"fichier":"07-plan-tables","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"db32ec95a7fd7ba6abf12bcfaa410216457c7657f7b3dc63c897694bb18fe130"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":15.94,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":6.09,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":9.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-chaise-trait","ratio":7.44,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":14.82,"role":"marque","seuil":3},{"jeton":"--couleur-liste-texte","ratio":14.82,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":13.81,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":5.81,"role":"texte","seuil":4.5},{"jeton":"--couleur-poignee","ratio":7.44,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":7.44,"role":"marque","seuil":3}],"sombre":[{"jeton":"--couleur-action-texte","ratio":8.45,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":7.6,"role":"texte","seuil":4.5},{"jeton":"--couleur-chaise-trait","ratio":8.89,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":14.62,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":11.07,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":6.72,"role":"texte","seuil":4.5},{"jeton":"--couleur-poignee","ratio":11.32,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":5.59,"role":"marque","seuil":3}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Écriture","Chaque geste s’enregistre aussitôt ; « Revenir en lecture » arrête l’écriture.","Revenir en lecture","Écriture","Défaire","État du plan","Brouillon","Proposé","Retenu","Bloqué","Imprimer…","Soirée d’essai","13 participants","2 tables · 18 sièges","3 tours","Préparer","Import","Participants","Tables et tours","Placer","Plan","Diagnostic","Génération","Propositions","Produire","Badges","Plan","Réservations","Tour affiché","1","2","3","Zoom +","Zoom −","Ajuster","Listes de noms","Légende","Plein écran","1. (libre)","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","9. (libre)","10. (libre)","1. (libre)","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1","m","disposition relative — l'encombrement dans la salle n'est pas vérifié","Diagnostic","Chœur de l’Anse","4 personnes : au moins 6 collisions cumulées sur les 3 tours, portées par au moins 2 paires distinctes","Société des Hérons","4 personnes : au moins 6 collisions cumulées sur les 3 tours, portées par au moins 2 paires distinctes","Cercle des Saules","3 personnes : au moins 3 collisions cumulées sur les 3 tours, portées par au moins 1 paire distincte","Tout l'événement","au moins 8 appartenances recroisées par personne","plafond a priori 11 rencontres pour 12 mobiles","Listes de travail","Titres de place non pourvus","Aucun.","Titres pressentis sans réservation","Basile Lanterneau","— titre pressenti","animation","Gaspard Marbrelle","— titre pressenti","animation","Réserve","Personnes en attente au tour 1","Basile Lanterneau","Capucine Pommerel","Gaspard Marbrelle","Ysaline Sablonnet","Anselme Givrecœur","Côme Fenouillet","Apolline Coudraie","Basile Lanterneau","Brunœil","Faustine Ormelin","Edgar Ardoisier","Bertille Cressonnier"],"theme":"clair","versionReelle":"2026.10.05.01"}, {"cadre":{"hauteur":700,"largeur":1366},"empreinte":"a812871e8e4d6b0df2c271c1a6a116aef45c1721657758efdb8ce617af109a6e","etape":"4","etat":"4d37d30748068354162752743c26aa7e0ab00d6e9b2aa1996b6f9fb410848280","facteurEchelle":1,"fichier":"07a-cibles-sieges","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"1f903bcd51222f7dc45f26fdd85602fc79cf85430ea940856e9d27d8baf471bd"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":15.94,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":6.09,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":9.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-chaise-pleine","ratio":15.09,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":7.44,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":14.82,"role":"marque","seuil":3},{"jeton":"--couleur-liste-texte","ratio":14.82,"role":"texte","seuil":4.5},{"jeton":"--couleur-marque","ratio":3.63,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":13.78,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":5.81,"role":"texte","seuil":4.5},{"jeton":"--couleur-poignee","ratio":7.44,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":7.44,"role":"marque","seuil":3}],"sombre":[{"jeton":"--couleur-action-texte","ratio":8.45,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":7.6,"role":"texte","seuil":4.5},{"jeton":"--couleur-chaise-pleine","ratio":14.94,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":8.89,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":14.62,"role":"marque","seuil":3},{"jeton":"--couleur-marque","ratio":3.5,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":11.07,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":6.72,"role":"texte","seuil":4.5},{"jeton":"--couleur-poignee","ratio":11.32,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":5.59,"role":"marque","seuil":3}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Lecture","Liste des événements","Clair","Sombre","À propos","Lecture","Rien ne change ; « Modifier » passe en écriture.","Modifier","Lecture","Défaire","État du plan","Brouillon","Proposé","Retenu","Bloqué","Imprimer…","Grande démonstration 1","260 participants","33 tables · 260 sièges","4 tours","Préparer","Import","Participants","Tables et tours","Placer","Plan","Diagnostic","Génération","Propositions","Produire","Badges","Plan","Réservations","Tour affiché","1","2","3","4","Zoom +","Zoom −","Ajuster","Listes de noms","Légende","Plein écran","1. (réservée, tous les tours)","Manon Urvançon","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Guillaume Luski…","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Fatima Korvouze","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Antoine Bralmault","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Manon Tolveron","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Philippe Voskeval","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Félix Voskande","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Michel Korveron","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Gabriel Ivreval","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Marc Vandergro…","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Étienne Tolvançon","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Sophie Pruvotte","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Fatima Abrevan…","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Marie Urvard","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Catherine Hulver…","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Camille Neskard","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Youssef Neskière","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Guillaume Dulvi…","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Philippe Ivroubre","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Claire Jusvivet","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Youssef Jusvard","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Louise Neskichon","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Hugo Bralmouze","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Gabriel Abrevault","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Louis Selvivet","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Jean Fralvumet","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Marie Bralmesson","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Thomas Selvignac","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Émilie Abrevière","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1. (réservée, tous les tours)","Geneviève Dulv…","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","1. (réservée, tous les tours)","François Obrouze","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","1. (réservée, tous les tours)","François Ivrivet","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","1. (réservée, tous les tours)","Samuel Bralmeux","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","1","m","disposition relative — l'encombrement dans la salle n'est pas vérifié","Diagnostic","Tout l'événement","écart d'itinéraire maximal : plancher 1, au moins 24 mobiles finissent sous leur plafond a priori","plafond a priori 24 rencontres pour 4 ancrés","plafond a priori 28 rencontres pour 29 ancrés et 227 mobiles","99 retours imposés par 33 ancrages","Listes de travail","Titres de place non pourvus","Aucun.","Titres pressentis sans réservation","Aucun.","Réserve","Personnes en attente au tour 1","Isabelle Mirvault","Félix Abrevande","Hugo Fralvançon","Manon Fralveux","Catherine Luskotte","Zoé WAMMERWOLM-MAWWEMMEL","Catherine Urvesson","Karim Hulvivet","Caroline Selveron","Catherine Korvault","Caroline Fralvotte","Geneviève Jusvesson","Paul Abrevumet","Léa Fralvault","Fatima Obrivet","Zoé Dulveval","Paul Korveux","Julie Luskière","Émilie Clovande","Paul Hulvande","Hugo Fralvesson","Isabelle Urvière","Thomas Rozvotte","Isabelle Bralmumet","Catherine Dulvouze","Marc Voskichon","Simon Mirvivet","Louise Clovault","Lucie Voskeux","Amina Eskignac","Luc Selvoubre","Étienne Fralvichon","Philippe Voskivet","Louise Quilmançon","Sophie Luskande","Pierre Abreveron","Philippe Neskeux","Amina Neskançon","Léa Tolvande","Antoine Quilmeron","Guillaume Clovumet","Michel Eskeux","Louise Hulvault","Samuel Eskumet","Claire Fralvivet","Léa Mirveux","Karim Gorzesson","Sylvie Quilmande","François Gorzichon","Amina Pruvesson","Lucie Quilmière","Fatima Korvesson","Nicolas Selvesson","Louise Neskesson","Lucie Tolvesson","Olivier Quilmesson","Guillaume Luskivet","Gabriel Neskault","Antoine Urvumet","Olivier Jusvançon","Simon Dulvançon","Chloé Gorzard","Gabriel Pruveux","Manon Clovière","Karim Tolveval","Hugo Bérêçàuvïlle-Dœûmôntëÿ","Claire Gorzeron","Hélène Luskumet","Louis Fralvande","Émilie Selvault","Léa Neskumet","Isabelle Korvichon","Michel Obrande","Isabelle Luskeval","Fatima Tolvoubre","Zoé Gorzière","Sylvie Korvignac","Antoine Obreval","Camille Tolvumet","Marie Dulvesson","Guillaume Tolvignac","Gabriel Mirvotte","Paul Neskignac","Raphaël Ivrotte","Marc Luskouze","Sylvie Jusvotte","Véronique Bralmoubre","Pierre Selvard","Marie Hulveux","Karim Fralveval","Philippe Gorzignac","Étienne Selvouze","Louis Hulvichon","Sylvie Urvoubre","Pierre Pruveval","Guillaume Hulvotte","Geneviève Abrevignac","Amina Bralmignac","Olivier Bralmançon","Marc Luskoubre","Geneviève Abrevoubre","Raphaël Korvoubre","Pierre Mirvard","Camille Jusvumet","Samuel Cloveval","Antoine Selvière","Véronique Jusvouze","Paul Neskivet","Samuel Dulvande","Lucie Selveval","Olivier Tolvichon","Fatima Hulvard","Alexandre Ivreux","Félix Korveval","Karim Urvichon","Émilie Dulvichon","Luc Tolvouze","Amina Quilmault","François Ivrouze","Hélène Gorzumet","Sophie Ivrault","Louise Bralmière","Nicolas Korvande","Geneviève Ivrumet","Geneviève Obresson","Guillaume Obrançon","Fatima Urvignac","Claire Bralmotte","Félix Hulvignac","François Clovotte","Philippe Dulvoubre","Samuel Rozveron","Pierre Hulvoubre","Émilie Urvouze","Félix Korvançon","Émilie Neskande","Chantal Mirvumet","Alexandre Korvotte","Zoé Eskeval","Nathalie Voskoubre","Sophie Pruvière","Paul Obrichon","Nathalie Eskière","Caroline Ivreron","Véronique Neskoubre","Anne Urvivet","Olivier Hulvançon","Karim Clovançon","Zoé Quilmotte","Félix Eskouze","Amina Rozvichon","Simon Fralvière","Pierre Hulvière","Raphaël Jusvault","Pierre Fralvoubre","Hélène Selveux","Caroline Rozvançon","Olivier Korvière","Nicolas Ivrichon","Félix Korvumet","Guillaume Selvumet","Michel Urvotte","Émilie Eskault","Hélène Bralmeval","Guillaume Hulvesson","Caroline Jusvoubre","Félix Luskeux","Antoine Pruvouze","Julie Quilmignac","Hugo Voskumet","Manon Quilmumet","Marc Voskouze","Samuel Abrevard","Manon Clovesson","Simon Hulvumet","Lucie Voskeron","Jean Neskeron","Manon Pruvivet","Sophie Clovard","Étienne Gorzeval","Gabriel Luskault","Émilie Korvard","Thomas Quilmichon","Alexandre Eskançon","Émilie Luskançon","Marc Dulvault","Hélène Rozveval","Julie Pruvançon","Louis Mirveron","François Eskard","Louis Ivrançon","Karim Clovivet","Léa Rozvande","Amina Hulvouze","Étienne Eskesson","Philippe Abreveval","Émilie Quilmeux","Simon Voskotte","Chantal Obrignac","Guillaume Eskotte","Léa Obrard","Guillaume Gorzivet","Geneviève Mirvançon","Julie Selvotte","Amina Mirvesson","Isabelle Luskignac","Chloé Obrumet","Anne Abrevesson","Fatima Rozvumet","Guillaume Mirvoubre","Véronique Rozvoubre","Claire Gorzotte","Antoine Ivrière","Isabelle Selvichon","Isabelle Urvault","Julie Voskesson","Chantal Abrevotte","Amina Neskotte","Étienne Voskançon","Zoé Fralveron","Marc Rozvouze","Sylvie Abreveux","Karim Pruvignac","Zoé Mirveval","Philippe Fralvignac","Gabriel Gorzeux","Jean Urveval"],"theme":"clair","versionReelle":"2026.10.05.01"}, {"cadre":{"hauteur":700,"largeur":1366},"empreinte":"cd2473a9bc973139dea77c6b87e7ce6a5966be9a91c97f342702be4ebb93b152","etape":"4","etat":"4d37d30748068354162752743c26aa7e0ab00d6e9b2aa1996b6f9fb410848280","facteurEchelle":1,"fichier":"07a-cibles-tables","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"a89c2da06737ee2076d76a87707ca71b5fde2b8a6540ad8254317bf60140e894"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":15.94,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":6.09,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":9.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-chaise-trait","ratio":7.44,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":14.82,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":13.78,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":5.81,"role":"texte","seuil":4.5},{"jeton":"--couleur-poignee","ratio":7.44,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":8.13,"role":"marque","seuil":3}],"sombre":[{"jeton":"--couleur-action-texte","ratio":8.45,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":7.6,"role":"texte","seuil":4.5},{"jeton":"--couleur-chaise-trait","ratio":8.89,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":14.62,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":11.07,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":6.72,"role":"texte","seuil":4.5},{"jeton":"--couleur-poignee","ratio":11.32,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":5.59,"role":"marque","seuil":3}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Lecture","Liste des événements","Clair","Sombre","À propos","Lecture","Rien ne change ; « Modifier » passe en écriture.","Modifier","Lecture","Défaire","État du plan","Brouillon","Proposé","Retenu","Bloqué","Imprimer…","Grande démonstration 1","260 participants","33 tables · 260 sièges","4 tours","Préparer","Import","Participants","Tables et tours","Placer","Plan","Diagnostic","Génération","Propositions","Produire","Badges","Plan","Réservations","Tour affiché","1","2","3","4","Zoom +","Zoom −","Ajuster","Listes de noms","Légende","Plein écran","5","m","disposition relative — l'encombrement dans la salle n'est pas vérifié","Diagnostic","Tout l'événement","écart d'itinéraire maximal : plancher 1, au moins 24 mobiles finissent sous leur plafond a priori","plafond a priori 24 rencontres pour 4 ancrés","plafond a priori 28 rencontres pour 29 ancrés et 227 mobiles","99 retours imposés par 33 ancrages","Listes de travail","Titres de place non pourvus","Aucun.","Titres pressentis sans réservation","Aucun.","Réserve","Personnes en attente au tour 1","Isabelle Mirvault","Félix Abrevande","Hugo Fralvançon","Manon Fralveux","Catherine Luskotte","Zoé WAMMERWOLM-MAWWEMMEL","Catherine Urvesson","Karim Hulvivet","Caroline Selveron","Catherine Korvault","Caroline Fralvotte","Geneviève Jusvesson","Paul Abrevumet","Léa Fralvault","Fatima Obrivet","Zoé Dulveval","Paul Korveux","Julie Luskière","Émilie Clovande","Paul Hulvande","Hugo Fralvesson","Isabelle Urvière","Thomas Rozvotte","Isabelle Bralmumet","Catherine Dulvouze","Marc Voskichon","Simon Mirvivet","Louise Clovault","Lucie Voskeux","Amina Eskignac","Luc Selvoubre","Étienne Fralvichon","Philippe Voskivet","Louise Quilmançon","Sophie Luskande","Pierre Abreveron","Philippe Neskeux","Amina Neskançon","Léa Tolvande","Antoine Quilmeron","Guillaume Clovumet","Michel Eskeux","Louise Hulvault","Samuel Eskumet","Claire Fralvivet","Léa Mirveux","Karim Gorzesson","Sylvie Quilmande","François Gorzichon","Amina Pruvesson","Lucie Quilmière","Fatima Korvesson","Nicolas Selvesson","Louise Neskesson","Lucie Tolvesson","Olivier Quilmesson","Guillaume Luskivet","Gabriel Neskault","Antoine Urvumet","Olivier Jusvançon","Simon Dulvançon","Chloé Gorzard","Gabriel Pruveux","Manon Clovière","Karim Tolveval","Hugo Bérêçàuvïlle-Dœûmôntëÿ","Claire Gorzeron","Hélène Luskumet","Louis Fralvande","Émilie Selvault","Léa Neskumet","Isabelle Korvichon","Michel Obrande","Isabelle Luskeval","Fatima Tolvoubre","Zoé Gorzière","Sylvie Korvignac","Antoine Obreval","Camille Tolvumet","Marie Dulvesson","Guillaume Tolvignac","Gabriel Mirvotte","Paul Neskignac","Raphaël Ivrotte","Marc Luskouze","Sylvie Jusvotte","Véronique Bralmoubre","Pierre Selvard","Marie Hulveux","Karim Fralveval","Philippe Gorzignac","Étienne Selvouze","Louis Hulvichon","Sylvie Urvoubre","Pierre Pruveval","Guillaume Hulvotte","Geneviève Abrevignac","Amina Bralmignac","Olivier Bralmançon","Marc Luskoubre","Geneviève Abrevoubre","Raphaël Korvoubre","Pierre Mirvard","Camille Jusvumet","Samuel Cloveval","Antoine Selvière","Véronique Jusvouze","Paul Neskivet","Samuel Dulvande","Lucie Selveval","Olivier Tolvichon","Fatima Hulvard","Alexandre Ivreux","Félix Korveval","Karim Urvichon","Émilie Dulvichon","Luc Tolvouze","Amina Quilmault","François Ivrouze","Hélène Gorzumet","Sophie Ivrault","Louise Bralmière","Nicolas Korvande","Geneviève Ivrumet","Geneviève Obresson","Guillaume Obrançon","Fatima Urvignac","Claire Bralmotte","Félix Hulvignac","François Clovotte","Philippe Dulvoubre","Samuel Rozveron","Pierre Hulvoubre","Émilie Urvouze","Félix Korvançon","Émilie Neskande","Chantal Mirvumet","Alexandre Korvotte","Zoé Eskeval","Nathalie Voskoubre","Sophie Pruvière","Paul Obrichon","Nathalie Eskière","Caroline Ivreron","Véronique Neskoubre","Anne Urvivet","Olivier Hulvançon","Karim Clovançon","Zoé Quilmotte","Félix Eskouze","Amina Rozvichon","Simon Fralvière","Pierre Hulvière","Raphaël Jusvault","Pierre Fralvoubre","Hélène Selveux","Caroline Rozvançon","Olivier Korvière","Nicolas Ivrichon","Félix Korvumet","Guillaume Selvumet","Michel Urvotte","Émilie Eskault","Hélène Bralmeval","Guillaume Hulvesson","Caroline Jusvoubre","Félix Luskeux","Antoine Pruvouze","Julie Quilmignac","Hugo Voskumet","Manon Quilmumet","Marc Voskouze","Samuel Abrevard","Manon Clovesson","Simon Hulvumet","Lucie Voskeron","Jean Neskeron","Manon Pruvivet","Sophie Clovard","Étienne Gorzeval","Gabriel Luskault","Émilie Korvard","Thomas Quilmichon","Alexandre Eskançon","Émilie Luskançon","Marc Dulvault","Hélène Rozveval","Julie Pruvançon","Louis Mirveron","François Eskard","Louis Ivrançon","Karim Clovivet","Léa Rozvande","Amina Hulvouze","Étienne Eskesson","Philippe Abreveval","Émilie Quilmeux","Simon Voskotte","Chantal Obrignac","Guillaume Eskotte","Léa Obrard","Guillaume Gorzivet","Geneviève Mirvançon","Julie Selvotte","Amina Mirvesson","Isabelle Luskignac","Chloé Obrumet","Anne Abrevesson","Fatima Rozvumet","Guillaume Mirvoubre","Véronique Rozvoubre","Claire Gorzotte","Antoine Ivrière","Isabelle Selvichon","Isabelle Urvault","Julie Voskesson","Chantal Abrevotte","Amina Neskotte","Étienne Voskançon","Zoé Fralveron","Marc Rozvouze","Sylvie Abreveux","Karim Pruvignac","Zoé Mirveval","Philippe Fralvignac","Gabriel Gorzeux","Jean Urveval"],"theme":"clair","versionReelle":"2026.10.05.01"}, -{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"49e63c497982815a720ade4b391049cd1ecdf1d60443ffad426e51691a59526b","etape":"5","etat":"730b6791b19e368d4b1c8beacf177d95231268071eaba0325fb2bd632c88a3c5","facteurEchelle":1,"fichier":"07b-nombre-de-tours","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"0727c70f557c198147c9ec95a5ab4ff75682fd999b58bce3fbc6113d0e847c3e"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-page-texte","ratio":16.19,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-page-texte","ratio":13.21,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Écriture","Chaque geste s’enregistre aussitôt ; « Revenir en lecture » arrête l’écriture.","Revenir en lecture","Écriture","Défaire","État du plan","Brouillon","Proposé","Retenu","Bloqué","Imprimer…","Soirée d’essai","13 participants","2 tables · 18 sièges","3 tours","Préparer","Import","Participants","Tables et tours","Placer","Plan","Diagnostic","Génération","Propositions","Produire","Badges","Tables et tours","Places par défaut","Nombre de tours","Passer de 3 à 2 tours ? Ce changement emporte :","Basile Lanterneau : réservation à la table 1 au tour 3, détruite","Annuler","Confirmer","Tables","2 tables · 18 sièges","Ajouter une table ronde","Ajouter une table carrée","Table","1","Ronde","Suit le défaut","Places","Supprimer","Table","2","Ronde","Surchargée","Places","Réaligner","Supprimer"],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"a618a3e15a9c6cdd290e610b27f8f7476a3f75e17d4f2a9244d0e2e14dbb60b7","etape":"5","etat":"730b6791b19e368d4b1c8beacf177d95231268071eaba0325fb2bd632c88a3c5","facteurEchelle":1,"fichier":"07b-nombre-de-tours","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"03cef0232a140d8423b0b66e257491ec69dfae7defc4228cf41bd94a2c2342e1"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-page-texte","ratio":16.19,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-page-texte","ratio":13.21,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Écriture","Chaque geste s’enregistre aussitôt ; « Revenir en lecture » arrête l’écriture.","Revenir en lecture","Écriture","Défaire","État du plan","Brouillon","Proposé","Retenu","Bloqué","Imprimer…","Soirée d’essai","13 participants","2 tables · 18 sièges","3 tours","Préparer","Import","Participants","Tables et tours","Placer","Plan","Diagnostic","Génération","Propositions","Produire","Badges","Tables et tours","Places par défaut","Nombre de tours","Passer de 3 à 2 tours ? Ce changement emporte :","Basile Lanterneau : réservation à la table 1 au tour 3, détruite","Annuler","Confirmer","Règle par défaut des objectifs","au moins une (OU)","toutes (ET)","Tables","2 tables · 18 sièges","Ajouter une table ronde","Ajouter une table carrée","Exporter les objectifs","Table","1","Ronde","Suit le défaut","Places","Supprimer","Objectif de la soirée","Règle de l'objectif","selon le réglage général (au moins une)","au moins une (OU)","toutes (ET)","Objectifs par tour","Table","2","Ronde","Surchargée","Places","Réaligner","Supprimer","Objectif de la soirée","Règle de l'objectif","selon le réglage général (au moins une)","au moins une (OU)","toutes (ET)","Objectifs par tour"],"theme":"clair","versionReelle":"2026.10.05.01"}, {"cadre":{"hauteur":700,"largeur":1366},"empreinte":"a0bf40574250872d4e9885290c3b86aa7cb9c94b30f57f124e533c5789f80e9c","etape":"6","etat":"35ff34c57ecc667cd737292aa7604b885bdc4149ee53480cf58d5f99d1229f88","facteurEchelle":1,"fichier":"08-animateurs-designes","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"8631b3356260b7d77c13b40e2d73b0847454600b3471baabe4de5ceb1fceadf1"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":15.94,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":6.09,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":9.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-chaise-pleine","ratio":15.09,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":7.44,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":14.82,"role":"marque","seuil":3},{"jeton":"--couleur-hachure","ratio":16.48,"role":"marque","seuil":3},{"jeton":"--couleur-liste-texte","ratio":14.82,"role":"texte","seuil":4.5},{"jeton":"--couleur-marque","ratio":3.63,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":13.81,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":5.81,"role":"texte","seuil":4.5},{"jeton":"--couleur-poignee","ratio":7.44,"role":"marque","seuil":3},{"jeton":"--couleur-selection","ratio":4.57,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":7.44,"role":"marque","seuil":3}],"sombre":[{"jeton":"--couleur-action-texte","ratio":8.45,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":7.6,"role":"texte","seuil":4.5},{"jeton":"--couleur-chaise-pleine","ratio":14.94,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":8.89,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":14.62,"role":"marque","seuil":3},{"jeton":"--couleur-hachure","ratio":15.21,"role":"marque","seuil":3},{"jeton":"--couleur-marque","ratio":3.5,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":11.07,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":6.72,"role":"texte","seuil":4.5},{"jeton":"--couleur-poignee","ratio":11.32,"role":"marque","seuil":3},{"jeton":"--couleur-selection","ratio":4.18,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":5.59,"role":"marque","seuil":3}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Écriture","Chaque geste s’enregistre aussitôt ; « Revenir en lecture » arrête l’écriture.","Revenir en lecture","Écriture","Défaire","État du plan","Brouillon","Proposé","Retenu","Bloqué","Imprimer…","Soirée d’essai","13 participants","2 tables · 18 sièges","3 tours","Préparer","Import","Participants","Tables et tours","Placer","Plan","Diagnostic","Génération","Propositions","Produire","Badges","Plan","Réservations","Tour affiché","1","2","3","Zoom +","Zoom −","Ajuster","Listes de noms","Légende","Plein écran","1. (réservée, tous les tours)","Basile Lanterneau","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","9. (libre)","10. (libre)","1. (libre)","— titre","animation","non pourvu","2. (réservée, tour 1)","Capucine Pommerel","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","1","m","disposition relative — l'encombrement dans la salle n'est pas vérifié","Diagnostic","Chœur de l’Anse","4 personnes : au moins 6 collisions cumulées sur les 3 tours, portées par au moins 2 paires distinctes","Société des Hérons","4 personnes : au moins 6 collisions cumulées sur les 3 tours, portées par au moins 2 paires distinctes","Cercle des Saules","3 personnes : au moins 3 collisions cumulées sur les 3 tours, portées par au moins 1 paire distincte","Tout l'événement","au moins 8 appartenances recroisées par personne","plafond a priori 11 rencontres pour 1 ancré et 11 mobiles","2 retours imposés par 1 ancrage","Listes de travail","Titres de place non pourvus","table 2 : titre","animation","non pourvu","Titres pressentis sans réservation","Gaspard Marbrelle","— titre pressenti","animation","Réserve","Personnes en attente au tour 1","Gaspard Marbrelle","Ysaline Sablonnet","Anselme Givrecœur","Côme Fenouillet","Apolline Coudraie","Basile Lanterneau","Brunœil","Faustine Ormelin","Edgar Ardoisier","Bertille Cressonnier","Table 2, siège 1","1. (libre)","— titre","animation","non pourvu","Choisissez d'abord une personne : dans la réserve, dans la liste des pressentis, ou par « Déplacer » dans son menu.","Portée","Tour 1","Tous les tours","Libellé du titre","Assigner","Réserver","Désigner","Retirer le titre"],"theme":"clair","versionReelle":"2026.10.05.01"}, {"cadre":{"hauteur":700,"largeur":1366},"empreinte":"edaf9f3298df2512b3e5d568c161b31ec9ce2ff0f0c81c3bb8f58e59ff70520c","etape":"7","etat":"35ff34c57ecc667cd737292aa7604b885bdc4149ee53480cf58d5f99d1229f88","facteurEchelle":1,"fichier":"09-refus-sur-place","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"db6e14d4a0a151e4440a515f0638de0e62676e2049b59f44672815283aa47b4e"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":15.94,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":6.09,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":9.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-chaise-pleine","ratio":15.09,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":7.44,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":14.82,"role":"marque","seuil":3},{"jeton":"--couleur-hachure","ratio":16.48,"role":"marque","seuil":3},{"jeton":"--couleur-liste-texte","ratio":14.82,"role":"texte","seuil":4.5},{"jeton":"--couleur-marque","ratio":3.63,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":13.81,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":5.81,"role":"texte","seuil":4.5},{"jeton":"--couleur-poignee","ratio":7.44,"role":"marque","seuil":3},{"jeton":"--couleur-refus","ratio":8.34,"role":"texte","seuil":4.5},{"jeton":"--couleur-selection","ratio":4.57,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":7.44,"role":"marque","seuil":3}],"sombre":[{"jeton":"--couleur-action-texte","ratio":8.45,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":7.6,"role":"texte","seuil":4.5},{"jeton":"--couleur-chaise-pleine","ratio":14.94,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":8.89,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":14.62,"role":"marque","seuil":3},{"jeton":"--couleur-hachure","ratio":15.21,"role":"marque","seuil":3},{"jeton":"--couleur-marque","ratio":3.5,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":11.07,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":6.72,"role":"texte","seuil":4.5},{"jeton":"--couleur-poignee","ratio":11.32,"role":"marque","seuil":3},{"jeton":"--couleur-refus","ratio":8.14,"role":"texte","seuil":4.5},{"jeton":"--couleur-selection","ratio":4.18,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":5.59,"role":"marque","seuil":3}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Écriture","Chaque geste s’enregistre aussitôt ; « Revenir en lecture » arrête l’écriture.","Revenir en lecture","Écriture","Défaire","État du plan","Brouillon","Proposé","Retenu","Bloqué","Imprimer…","Soirée d’essai","13 participants","2 tables · 18 sièges","3 tours","Préparer","Import","Participants","Tables et tours","Placer","Plan","Diagnostic","Génération","Propositions","Produire","Badges","Plan","Réservations","Tour affiché","1","2","3","Zoom +","Zoom −","Ajuster","Listes de noms","Légende","Plein écran","1. (réservée, tous les tours)","Basile Lanterneau","—","animation","2. (libre)","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","9. (libre)","10. (libre)","1. (libre)","— titre","animation","non pourvu","2. (réservée, tour 1)","Capucine Pommerel","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","Table 1, siège 1","Le siège 1 est réservé à une autre personne à tous les tours : déplacer sa réservation libère la place.","Choisir une place","Fermer","1","m","disposition relative — l'encombrement dans la salle n'est pas vérifié","Diagnostic","Chœur de l’Anse","4 personnes : au moins 6 collisions cumulées sur les 3 tours, portées par au moins 2 paires distinctes","Société des Hérons","4 personnes : au moins 6 collisions cumulées sur les 3 tours, portées par au moins 2 paires distinctes","Cercle des Saules","3 personnes : au moins 3 collisions cumulées sur les 3 tours, portées par au moins 1 paire distincte","Tout l'événement","au moins 8 appartenances recroisées par personne","plafond a priori 11 rencontres pour 1 ancré et 11 mobiles","2 retours imposés par 1 ancrage","Listes de travail","Titres de place non pourvus","table 2 : titre","animation","non pourvu","Titres pressentis sans réservation","Gaspard Marbrelle","— titre pressenti","animation","Réserve","Personnes en attente au tour 1","Gaspard Marbrelle","Ysaline Sablonnet","Anselme Givrecœur","Côme Fenouillet","Apolline Coudraie","Basile Lanterneau","Brunœil","Faustine Ormelin","Edgar Ardoisier","Bertille Cressonnier","Table 1, siège 1","1. (réservée, tous les tours)","Basile Lanterneau","—","animation","Personne choisie :","Gaspard Marbrelle","Assigner","Retirer la réservation","Retirer le titre","Oublier le choix","Menu de","Basile Lanterneau","Déplacer","Échanger","Retirer du tour","Déplacer sa réservation","Exclure","Mettre en évidence","Ouvrir la fiche"],"theme":"clair","versionReelle":"2026.10.05.01"}, {"cadre":{"hauteur":700,"largeur":1366},"empreinte":"c35f4720e6dac6dc98262aba6615905ba8f6ab28d6c897551cdf07ed5c473d2c","etape":"8","etat":"4d37d30748068354162752743c26aa7e0ab00d6e9b2aa1996b6f9fb410848280","facteurEchelle":1,"fichier":"10-diagnostic","graine":null,"image":{"hauteur":700,"largeur":1366,"sha256":"6d9e4c48b101c40a4ef97fef2dc2241ab37bf5106aa362255b1d3357aecaef3d"},"instant":"2026-10-06T19:00:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":15.94,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":6.09,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":9.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":13.78,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":5.81,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-action-texte","ratio":8.45,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":7.6,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":11.07,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":6.72,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Lecture","Liste des événements","Clair","Sombre","À propos","Lecture","Rien ne change ; « Modifier » passe en écriture.","Modifier","Lecture","Défaire","État du plan","Brouillon","Proposé","Retenu","Bloqué","Imprimer…","Grande démonstration 1","260 participants","33 tables · 260 sièges","4 tours","Préparer","Import","Participants","Tables et tours","Placer","Plan","Diagnostic","Génération","Propositions","Produire","Badges","Diagnostic","Tout l'événement","écart d'itinéraire maximal : plancher 1, au moins 24 mobiles finissent sous leur plafond a priori","aucune relance ne le réduit : il tient aux tables pleines dont les capacités ou les ancrages diffèrent","plafond a priori 24 rencontres pour 4 ancrés","seules les capacités et les réservations le changent : aucune relance ne le dépasse","plafond a priori 28 rencontres pour 29 ancrés et 227 mobiles","seules les capacités et les réservations le changent : aucune relance ne le dépasse","99 retours imposés par 33 ancrages","les réservations les imposent : ils ne comptent pas parmi les retours que choisit le moteur"],"theme":"clair","versionReelle":"2026.10.05.01"}, -{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"283c5388c8e3dc4c95798a275c280fb9fdee36d74aa2d9f13777308ec920a497","etape":"9","etat":"9f6bd933d5b9f30eea6be42a7d0a56c0e92cdac5ab3ce7e1f363ca763eb63c7f","facteurEchelle":1,"fichier":"11-propositions","graine":4242,"image":{"hauteur":700,"largeur":1366,"sha256":"cda3c6f4f0d90416d4067be44c72d69611eb4b60e8d9e98beee2bd72051cbe2d"},"instant":"2026-10-06T19:02:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":15.94,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":6.09,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":9.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":14.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":5.81,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-action-texte","ratio":8.45,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":7.6,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":11.02,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":6.72,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Écriture","Chaque geste s’enregistre aussitôt ; « Revenir en lecture » arrête l’écriture.","Revenir en lecture","Écriture","Défaire","État du plan","Brouillon","Proposé","Retenu","Bloqué","Imprimer…","Soirée d’essai","13 participants","2 tables · 18 sièges","3 tours","Préparer","Import","Participants","Tables et tours","Placer","Plan","Diagnostic","Génération","Propositions","Produire","Badges","Propositions","Propositions classées","Effacer les propositions","Ordre du classement, chaque critère au plus faible d'abord :","écart au plafond a priori",", puis","excédent de collisions",", puis","collisions cumulées",", puis","rencontres répétées",", puis","redondance d'appartenance",", puis","l'identifiant croissant",".","Personnes rencontrées","Collisions d'appartenance","Rencontres répétées et retours","Diversité d'appartenance","Manque et plafond a priori","Rang","Proposition","Personnes distinctes rencontrées, minimum : tous","Personnes distinctes rencontrées, minimum : mobiles","Personnes distinctes rencontrées, minimum : ancrés","Collisions cumulées (paire et tour)","Paires distinctes de même appartenance","Excédent de collisions (retrouvailles au-delà de la première)","Rencontres répétées choisies (paires)","Rencontres d'une même paire, au plus","Retours choisis à une table déjà occupée","Appartenances rencontrées A(p), minimum","Redondance d'appartenance, somme Σ r(p)","Taux de diversité d(p), minimum","Plus grand manque (personnes)","Personnes à qui il en manque","Écart d'itinéraire maximal (personnes)","Écart au plafond a priori maximal (personnes)","Certificat","Imposé par les réservations","Commandes","Placement retenu","3","Tel que la proposition n° 3.","11","11","11","26","15","11","30","3","11","3","85","0,27","0","0","0","0","minimum atteint","2 retours imposés par 1 ancrage.","Aucune rencontre répétée imposée.","Voir sur le plan","1","3","retenue","11","11","11","26","15","11","30","3","11","3","85","0,27","0","0","0","0","minimum atteint","2 retours imposés par 1 ancrage.","Aucune rencontre répétée imposée.","Voir sur le plan","Retenir","2","1","11","11","11","27","15","12","19","3","16","3","85","0,27","0","0","0","0","minimum atteint","2 retours imposés par 1 ancrage.","Aucune rencontre répétée imposée.","Voir sur le plan","Retenir","3","2","11","11","11","33","15","18","54","3","16","3","85","0,27","0","0","0","0","minimum atteint","2 retours imposés par 1 ancrage.","Aucune rencontre répétée imposée.","Voir sur le plan","Retenir","4","5","10","10","10","20","15","5","22","3","12","3","83","0,27","1","1","1","1","atteignabilité inconnue","2 retours imposés par 1 ancrage.","Aucune rencontre répétée imposée.","Voir sur le plan","Retenir","5","4","10","10","11","31","14","17","46","3","17","3","82","0,30","1","4","0","1","atteignabilité inconnue","2 retours imposés par 1 ancrage.","Aucune rencontre répétée imposée.","Voir sur le plan","Retenir","r(p) : le nombre de personnes que p rencontre et qui ont une appartenance, moins A(p), le nombre d'appartenances différentes qu'elles portent ; à 0, aucune appartenance n'est recroisée. La colonne donne la somme sur toutes les personnes, Σ r(p), dernier critère du classement avant le numéro. Plus elle est basse, mieux c'est.","Cible de la somme de redondance","La liste des événements montre, pour le placement retenu, la différence sans signe entre cette cible et la somme Σ r(p) de la colonne « Redondance d'appartenance ». Un nombre entier, 0 ou plus."],"theme":"clair","versionReelle":"2026.10.05.01"}, +{"cadre":{"hauteur":700,"largeur":1366},"empreinte":"92df1b762ec96e262fa0a43a942faa68679d7ddc0742fd1274a6fa9065029072","etape":"9","etat":"9f6bd933d5b9f30eea6be42a7d0a56c0e92cdac5ab3ce7e1f363ca763eb63c7f","facteurEchelle":1,"fichier":"11-propositions","graine":4242,"image":{"hauteur":700,"largeur":1366,"sha256":"06603630ce33ce3713b0a1a195c329aac27eba9b1c87cd64a00ccd1617a06ba5"},"instant":"2026-10-06T19:02:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":15.94,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":6.09,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":9.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":14.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":5.81,"role":"texte","seuil":4.5}],"sombre":[{"jeton":"--couleur-action-texte","ratio":8.45,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":7.6,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte","ratio":11.02,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":6.72,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Écriture","Chaque geste s’enregistre aussitôt ; « Revenir en lecture » arrête l’écriture.","Revenir en lecture","Écriture","Défaire","État du plan","Brouillon","Proposé","Retenu","Bloqué","Imprimer…","Soirée d’essai","13 participants","2 tables · 18 sièges","3 tours","Préparer","Import","Participants","Tables et tours","Placer","Plan","Diagnostic","Génération","Propositions","Produire","Badges","Propositions","Propositions classées","Effacer les propositions","Ordre du classement, chaque critère au plus faible d'abord :","écart au plafond a priori",", puis","excédent de collisions",", puis","collisions cumulées",", puis","rencontres répétées",", puis","redondance d'appartenance",", puis","l'identifiant croissant",".","Personnes rencontrées","Collisions d'appartenance","Rencontres répétées et retours","Diversité d'appartenance","Manque et plafond a priori","Rang","Proposition","Personnes distinctes rencontrées, minimum : tous","Personnes distinctes rencontrées, minimum : mobiles","Personnes distinctes rencontrées, minimum : ancrés","Collisions cumulées (paire et tour)","Paires distinctes de même appartenance","Excédent de collisions (retrouvailles au-delà de la première)","Rencontres répétées choisies (paires)","Rencontres d'une même paire, au plus","Retours choisis à une table déjà occupée","Appartenances rencontrées A(p), minimum","Redondance d'appartenance, somme Σ r(p)","Taux de diversité d(p), minimum","Plus grand manque (personnes)","Personnes à qui il en manque","Écart d'itinéraire maximal (personnes)","Écart au plafond a priori maximal (personnes)","Certificat","Imposé par les réservations","Commandes","Placement retenu","3","Tel que la proposition n° 3.","11","11","11","26","15","11","30","3","11","3","85","0,27","0","0","0","0","minimum atteint","2 retours imposés par 1 ancrage.","Aucune rencontre répétée imposée.","Voir sur le plan","1","3","retenue","11","11","11","26","15","11","30","3","11","3","85","0,27","0","0","0","0","minimum atteint","2 retours imposés par 1 ancrage.","Aucune rencontre répétée imposée.","Voir sur le plan","Retenir","2","1","11","11","11","27","15","12","19","3","16","3","85","0,27","0","0","0","0","minimum atteint","2 retours imposés par 1 ancrage.","Aucune rencontre répétée imposée.","Voir sur le plan","Retenir","3","2","11","11","11","33","15","18","54","3","16","3","85","0,27","0","0","0","0","minimum atteint","2 retours imposés par 1 ancrage.","Aucune rencontre répétée imposée.","Voir sur le plan","Retenir","4","5","10","10","10","20","15","5","22","3","12","3","83","0,27","1","1","1","1","atteignabilité inconnue","2 retours imposés par 1 ancrage.","Aucune rencontre répétée imposée.","Voir sur le plan","Retenir","5","4","10","10","11","31","14","17","46","3","17","3","82","0,30","1","4","0","1","atteignabilité inconnue","2 retours imposés par 1 ancrage.","Aucune rencontre répétée imposée.","Voir sur le plan","Retenir","r(p) : le nombre de personnes que p rencontre et qui ont une appartenance, moins A(p), le nombre d'appartenances différentes qu'elles portent ; à 0, aucune appartenance n'est recroisée. La colonne donne la somme sur toutes les personnes, Σ r(p), critère du classement à la place que lui donne l'ordre écrit au-dessus du tableau. Plus elle est basse, mieux c'est.","Cible de la somme de redondance","La liste des événements montre, pour le placement retenu, la différence sans signe entre cette cible et la somme Σ r(p) de la colonne « Redondance d'appartenance ». Un nombre entier, 0 ou plus."],"theme":"clair","versionReelle":"2026.10.05.01"}, {"cadre":{"hauteur":700,"largeur":1366},"empreinte":"7a490a0996a770b8b52feea3af6421cbcb09a7efa2727572d8ac13409845cd12","etape":"11","etat":"9f6bd933d5b9f30eea6be42a7d0a56c0e92cdac5ab3ce7e1f363ca763eb63c7f","facteurEchelle":1,"fichier":"19-ajustement","graine":4242,"image":{"hauteur":700,"largeur":1366,"sha256":"628d4679190e73a8c50590eb755d688902318b43b2ee14c2aab0ec2ece724877"},"instant":"2026-10-06T19:02:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":15.94,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":6.09,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":9.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-chaise-pleine","ratio":15.09,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":7.44,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":14.82,"role":"marque","seuil":3},{"jeton":"--couleur-fantome","ratio":4.34,"role":"marque","seuil":3},{"jeton":"--couleur-hachure","ratio":16.48,"role":"marque","seuil":3},{"jeton":"--couleur-liste-texte","ratio":14.82,"role":"texte","seuil":4.5},{"jeton":"--couleur-marque","ratio":3.63,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":13.81,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":5.81,"role":"texte","seuil":4.5},{"jeton":"--couleur-pastille-fond","ratio":13.44,"role":"marque","seuil":3},{"jeton":"--couleur-poignee","ratio":7.44,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":7.44,"role":"marque","seuil":3}],"sombre":[{"jeton":"--couleur-action-texte","ratio":8.45,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":7.6,"role":"texte","seuil":4.5},{"jeton":"--couleur-chaise-pleine","ratio":14.94,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":8.89,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":14.62,"role":"marque","seuil":3},{"jeton":"--couleur-fantome","ratio":4.1,"role":"marque","seuil":3},{"jeton":"--couleur-hachure","ratio":15.21,"role":"marque","seuil":3},{"jeton":"--couleur-marque","ratio":3.5,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":11.02,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":6.72,"role":"texte","seuil":4.5},{"jeton":"--couleur-pastille-fond","ratio":11.62,"role":"marque","seuil":3},{"jeton":"--couleur-pastille-texte","ratio":14.51,"role":"texte","seuil":4.5},{"jeton":"--couleur-poignee","ratio":11.32,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":5.59,"role":"marque","seuil":3}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Écriture","Chaque geste s’enregistre aussitôt ; « Revenir en lecture » arrête l’écriture.","Revenir en lecture","Écriture","Défaire","État du plan","Brouillon","Proposé","Retenu","Bloqué","Imprimer…","Soirée d’essai","13 participants","2 tables · 18 sièges","3 tours","Préparer","Import","Participants","Tables et tours","Placer","Plan","Diagnostic","Génération","Propositions","Produire","Badges","Plan","Placement retenu","Tour affiché","1","2","3","Zoom +","Zoom −","Ajuster","Listes de noms","Légende","Plein écran","9","1. (réservée, tous les tours)","Basile Lanterneau","—","animation","2.","Gaspard Marbrelle","3.","Ysaline Sablonnet","4.","Anselme Givrecœur","5.","Côme Fenouillet","6.","Apolline Coudraie","7.","Brunœil","8.","Faustine Ormelin","9.","Edgar Ardoisier","10.","Bertille Cressonnier","1.","Basile Lanterneau","— titre","animation","non pourvu","2. (réservée, tour 1)","Capucine Pommerel","3. (libre)","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","Gaspard Marbrelle","déplace dans le placement retenu","1","m","disposition relative — l'encombrement dans la salle n'est pas vérifié","Diagnostic","Chœur de l’Anse","4 personnes : au moins 6 collisions cumulées sur les 3 tours, portées par au moins 2 paires distinctes","Société des Hérons","4 personnes : au moins 6 collisions cumulées sur les 3 tours, portées par au moins 2 paires distinctes","Cercle des Saules","3 personnes : au moins 3 collisions cumulées sur les 3 tours, portées par au moins 1 paire distincte","Tout l'événement","au moins 8 appartenances recroisées par personne","plafond a priori 11 rencontres pour 1 ancré et 11 mobiles","2 retours imposés par 1 ancrage","Listes de travail","Titres de place non pourvus","table 2 : titre","animation","non pourvu","Titres pressentis sans réservation","Gaspard Marbrelle","— titre pressenti","animation","Réserve","Personnes en attente au tour 1","Personne en attente à ce tour."],"theme":"clair","versionReelle":"2026.10.05.01"}, {"cadre":{"hauteur":700,"largeur":1366},"empreinte":"4a417f7a6cbad1fd9f89c4ecb7cc669bcef7fb8a38117d4e6a55c9b1d7ecf035","etape":"11","etat":"33c46c8fcafd16adbd616315240d975530137f5bf1c09e54e0149b52de4f94ac","facteurEchelle":1,"fichier":"20-mode-lecture","graine":4242,"image":{"hauteur":700,"largeur":1366,"sha256":"4f4cad699bed04e20bd932ff8f11dada090e01de4389928914d415b555e097df"},"instant":"2026-10-06T19:02:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":15.94,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":6.09,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":9.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-chaise-pleine","ratio":15.09,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":7.44,"role":"marque","seuil":3},{"jeton":"--couleur-conflit-appartenance","ratio":8.92,"role":"marque","seuil":3},{"jeton":"--couleur-contour-fort","ratio":12.3,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":14.82,"role":"marque","seuil":3},{"jeton":"--couleur-hachure","ratio":16.48,"role":"marque","seuil":3},{"jeton":"--couleur-liste-texte","ratio":14.82,"role":"texte","seuil":4.5},{"jeton":"--couleur-marque","ratio":3.63,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":13.78,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":5.81,"role":"texte","seuil":4.5},{"jeton":"--couleur-pastille-fond","ratio":13.44,"role":"marque","seuil":3},{"jeton":"--couleur-poignee","ratio":7.44,"role":"marque","seuil":3},{"jeton":"--couleur-refus","ratio":8.34,"role":"texte","seuil":4.5},{"jeton":"--couleur-selection","ratio":4.57,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":7.44,"role":"marque","seuil":3}],"sombre":[{"jeton":"--couleur-action-texte","ratio":8.45,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":7.6,"role":"texte","seuil":4.5},{"jeton":"--couleur-chaise-pleine","ratio":14.94,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":8.89,"role":"marque","seuil":3},{"jeton":"--couleur-conflit-appartenance","ratio":6.81,"role":"marque","seuil":3},{"jeton":"--couleur-contour-fort","ratio":11.62,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":14.62,"role":"marque","seuil":3},{"jeton":"--couleur-hachure","ratio":15.21,"role":"marque","seuil":3},{"jeton":"--couleur-marque","ratio":3.5,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":11.02,"role":"texte","seuil":4.5},{"jeton":"--couleur-page-texte-secondaire","ratio":6.72,"role":"texte","seuil":4.5},{"jeton":"--couleur-pastille-fond","ratio":11.62,"role":"marque","seuil":3},{"jeton":"--couleur-pastille-texte","ratio":14.51,"role":"texte","seuil":4.5},{"jeton":"--couleur-poignee","ratio":11.32,"role":"marque","seuil":3},{"jeton":"--couleur-refus","ratio":8.14,"role":"texte","seuil":4.5},{"jeton":"--couleur-selection","ratio":4.18,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":5.59,"role":"marque","seuil":3}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Lecture","Liste des événements","Clair","Sombre","À propos","Lecture","Rien ne change ; « Modifier » passe en écriture.","Modifier","Lecture","Défaire","État du plan","Brouillon","Proposé","Retenu","Bloqué","Imprimer…","Soirée d’essai","13 participants","2 tables · 18 sièges","3 tours","Préparer","Import","Participants","Tables et tours","Placer","Plan","Diagnostic","Génération","Propositions","Produire","Badges","Plan","Placement retenu","Tour affiché","1","2","3","Zoom +","Zoom −","Ajuster","Listes de noms","Légende","Plein écran","7","1. (réservée, tous les tours)","Basile Lanterneau","—","animation","2.","Ysaline Sablonnet","3.","Anselme Givrecœur","4.","Côme Fenouillet","5.","Apolline Coudraie","6.","Brunœil","7.","Faustine Ormelin","8.","Edgar Ardoisier","9.","Bertille Cressonnier","10. (libre)","1.","Gaspard Marbrelle","— titre","animation","non pourvu","2. (réservée, tour 1)","Capucine Pommerel","3.","Basile Lanterneau","4. (libre)","5. (libre)","6. (libre)","7. (libre)","8. (libre)","Table 2, siège 4","L'événement est ouvert en lecture : « Modifier » permet de le changer.","Modifier","Fermer","1","m","disposition relative — l'encombrement dans la salle n'est pas vérifié","Diagnostic","Chœur de l’Anse","4 personnes : au moins 6 collisions cumulées sur les 3 tours, portées par au moins 2 paires distinctes","Société des Hérons","4 personnes : au moins 6 collisions cumulées sur les 3 tours, portées par au moins 2 paires distinctes","Cercle des Saules","3 personnes : au moins 3 collisions cumulées sur les 3 tours, portées par au moins 1 paire distincte","Tout l'événement","au moins 8 appartenances recroisées par personne","plafond a priori 11 rencontres pour 1 ancré et 11 mobiles","2 retours imposés par 1 ancrage","Listes de travail","Titres de place non pourvus","table 2 : titre","animation","non pourvu","Titres pressentis sans réservation","Gaspard Marbrelle","— titre pressenti","animation","Réserve","Personnes en attente au tour 1","Personne en attente à ce tour.","Table 2, siège 4","4. (libre)","Choisissez d'abord une personne : dans la réserve, dans la liste des pressentis, ou par « Déplacer » dans son menu.","Portée","Tour 1","Tous les tours","Libellé du titre","Assigner","Réserver","Donner un titre","Désigner"],"theme":"clair","versionReelle":"2026.10.05.01"}, {"cadre":{"hauteur":700,"largeur":1366},"empreinte":"157424157c0e73e2fbcd74ec125a6f13d3d1a8347aa14f70942bbc072b63083f","etape":"12","etat":"9f6bd933d5b9f30eea6be42a7d0a56c0e92cdac5ab3ce7e1f363ca763eb63c7f","facteurEchelle":1,"fichier":"22-plan-brouillon","graine":4242,"image":{"hauteur":700,"largeur":1366,"sha256":"e177c04ec9d589d11046f63c5299542c3cb5f7422446597e6e9cb497768d300d"},"instant":"2026-10-06T19:02:00-04:00","mode":"chromium-headless-shell","pdf":null,"source":"navigateur","styles":{"clair":[{"jeton":"--couleur-bandeau-fond","ratio":15.94,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":13.44,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":9.05,"role":"texte","seuil":4.5},{"jeton":"--couleur-chaise-pleine","ratio":16.48,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":8.13,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":16.18,"role":"marque","seuil":3},{"jeton":"--couleur-filigrane","ratio":5.1,"role":"marque","seuil":3},{"jeton":"--couleur-hachure","ratio":16.48,"role":"marque","seuil":3},{"jeton":"--couleur-liste-texte","ratio":16.18,"role":"texte","seuil":4.5},{"jeton":"--couleur-marque","ratio":3.63,"role":"marque","seuil":3},{"jeton":"--couleur-page-texte","ratio":16.19,"role":"texte","seuil":4.5},{"jeton":"--couleur-pastille-fond","ratio":13.44,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":8.13,"role":"marque","seuil":3}],"sombre":[{"jeton":"--couleur-bandeau-fond","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte","ratio":12.84,"role":"texte","seuil":4.5},{"jeton":"--couleur-bandeau-texte-secondaire","ratio":7.6,"role":"texte","seuil":4.5},{"jeton":"--couleur-chaise-pleine","ratio":16.48,"role":"marque","seuil":3},{"jeton":"--couleur-chaise-trait","ratio":8.13,"role":"marque","seuil":3},{"jeton":"--couleur-echelle","ratio":16.18,"role":"marque","seuil":3},{"jeton":"--couleur-filigrane","ratio":5.1,"role":"marque","seuil":3},{"jeton":"--couleur-hachure","ratio":16.48,"role":"marque","seuil":3},{"jeton":"--couleur-marque","ratio":3.63,"role":"marque","seuil":3},{"jeton":"--couleur-papier","ratio":13.44,"role":"texte","seuil":4.5},{"jeton":"--couleur-pastille-fond","ratio":13.44,"role":"marque","seuil":3},{"jeton":"--couleur-table-trait","ratio":8.13,"role":"marque","seuil":3},{"jeton":"rgb(24, 33, 47)","ratio":16.19,"role":"texte","seuil":4.5},{"jeton":"rgb(28, 33, 40)","ratio":16.18,"role":"texte","seuil":4.5}]},"texte":["Gestion table tournante Libre","construction de documentation","documentation","Écriture","Liste des événements","Clair","Sombre","À propos","Aperçu avant impression","Écriture","Plan de salle","Liste d'accueil","Feuilles par table","Tour imprimé","Tour 1","Tour 2","Tour 3","Listes de noms","Pages du plan","Découpage proposé","Tout sur une page","Découper en pages","Papier :","A4","Changer","Imprimer","Fermer l'aperçu","Page 888 sur 888 — colonne WW, rangée 888","au-dessus : page 888","à droite : page 888","au-dessous : page 888","à gauche : page 888","500","m","disposition relative — l'encombrement dans la salle n'est pas vérifié","listes de noms hors d'échelle — le plan à l'échelle du bâtiment s'imprime listes masquées","Soirée d’essai","Date non fixée","Tour 1","Placement du","6 octobre 2026, 19 h 00","Version","construction de documentation","6","1","1. (réservée, tous les tours)","Basile Lanterneau","—","animation","2.","Ysaline Sablonnet","3.","Anselme Givrecœur","4.","Côme Fenouillet","5.","Apolline Coudraie","6.","Brunœil","7.","Faustine Ormelin","8.","Edgar Ardoisier","9. (libre)","10. (libre)","1.","Gaspard Marbrelle","— titre","animation","non pourvu","2. (réservée, tour 1)","Capucine Pommerel","3.","Basile Lanterneau","4.","Bertille Cressonnier","5. (libre)","6. (libre)","7. (libre)","8. (libre)","Page 1 sur 1 — colonne A, rangée 1","2","m","disposition relative — l'encombrement dans la salle n'est pas vérifié","listes de noms hors d'échelle — le plan à l'échelle du bâtiment s'imprime listes masquées","Soirée d’essai","Date non fixée","Tour 1","Placement du","6 octobre 2026, 19 h 00","Version","construction de documentation","BROUILLON"],"theme":"clair","versionReelle":"2026.10.05.01"}, diff --git a/scripts/documentation.js b/scripts/documentation.js index 5974c0c..0e3db48 100644 --- a/scripts/documentation.js +++ b/scripts/documentation.js @@ -307,15 +307,22 @@ export function tableau(entetes, lignes) { /** * Le tableau des réglages (§ 19.8) : chaque entrée de la définition, dans * son ordre — son libellé, qui porte l'unité d'un champ, l'écran qui la - * montre, sa valeur par défaut. + * montre, sa valeur par défaut. Celle d'un choix est le libellé de sa valeur, + * . ; celle de l'ordre des priorités, ses paliers dans + * l'ordre, séparés par « puis », chaque critère sous son nom complet + * (mobilier.critere.), ceux d'un palier partagé joints par « avec ». * * @param {ReadonlyArray} [definition] * @returns {string} */ export function tableauReglages(definition = REGLAGES_GENERATION) { - const defaut = ({ genre, defaut: valeur, cle }) => { + const defaut = ({ genre, defaut: valeur, cle, etiquette }) => { if (genre === 'champ') return nombre(valeur); if (genre === 'bascule') return valeur ? 'cochée' : 'décochée'; + if (genre === 'choix') return t(`${etiquette}.${valeur}`); + if (genre === 'ordre') { + return valeur.map((palier) => palier.map(({ critere }) => t(`mobilier.critere.${critere}`)).join(' avec ')).join(', puis '); + } throw new RangeError(`reglages : genre inconnu ${JSON.stringify(genre)} pour ${cle}`); }; return tableau( diff --git a/scripts/documentation.test.js b/scripts/documentation.test.js index 4d8347e..e83b484 100644 --- a/scripts/documentation.test.js +++ b/scripts/documentation.test.js @@ -314,10 +314,28 @@ describe('documentation : les producteurs, chacun sur sa source unique (§ 19.8) test('reglages : une ligne par entrée de la définition, son libellé, son écran et son défaut ; une entrée ajoutée paraît', () => { const lignes = lignesDuTableau(tableauReglages()); assert.equal(lignes.length, REGLAGES_GENERATION.length); + // Le défaut écrit de chaque genre : un nombre, une case ; le libellé du + // mode par défaut et les paliers de l'ordre par défaut, écrits en toutes + // lettres. + const ecrit = { + champ: ({ defaut }) => nombre(defaut), + bascule: ({ defaut }) => (defaut ? 'cochée' : 'décochée'), + choix: () => 'le plus de places servies', + ordre: () => + 'écart au plafond a priori, puis excédent de collisions, puis collisions cumulées, puis tours hors objectif, ' + + "puis rencontres répétées et retours, puis animateurs non rencontrés, puis redondance d'appartenance", + }; REGLAGES_GENERATION.forEach((entree, rang) => { - const attendu = entree.genre === 'champ' ? nombre(entree.defaut) : entree.defaut ? 'cochée' : 'décochée'; - assert.deepEqual(lignes[rang], [t(entree.etiquette), t(`coquille.section.${entree.section}`), attendu]); + assert.deepEqual(lignes[rang], [t(entree.etiquette), t(`coquille.section.${entree.section}`), ecrit[entree.genre](entree)]); }); + // Sept bascules, le mode et l'ordre (§ 5.3, § 5.7). + assert.equal(REGLAGES_GENERATION.filter(({ genre }) => genre === 'bascule').length, 7); + const ordre = REGLAGES_GENERATION.find(({ genre }) => genre === 'ordre'); + const partage = { ...ordre, defaut: [[{ critere: 'excedentCollisions', poids: 2 }, { critere: 'redondance', poids: 3 }], [{ critere: 'collisionsCumulees', poids: 1 }]] }; + assert.equal( + lignesDuTableau(tableauReglages([partage]))[0][2], + `${t('mobilier.critere.excedentCollisions')} avec ${t('mobilier.critere.redondance')}, puis ${t('mobilier.critere.collisionsCumulees')}`, + ); const ajoutee = { ...REGLAGES_GENERATION[0], etiquette: 'generation.reglage.graine', defaut: 4_294_967_295 }; const avecAjout = lignesDuTableau(tableauReglages([...REGLAGES_GENERATION, ajoutee])); assert.deepEqual(avecAjout.at(-1), [t('generation.reglage.graine'), t('coquille.section.generation'), '4\u{202F}294\u{202F}967\u{202F}295']); diff --git a/src/application/evaluation.js b/src/application/evaluation.js index 6156c31..05c8d1b 100644 --- a/src/application/evaluation.js +++ b/src/application/evaluation.js @@ -49,6 +49,11 @@ import { configurationDe } from './configuration.js'; export { CIBLE_REDONDANCE_PAR_DEFAUT, cibleRedondance }; +// Les modes des objectifs, le défaut en tête, et les cases qui font compter +// chaque critère, tels que le registre les tient (contrainte 35) : +// l'interface, qui n'importe pas le moteur, les lit ici. +export { CASES, MODES_OBJECTIFS } from '../moteur/criteres.js'; + /** * @typedef {Object} LigneEvaluation * @property {number} id celui de la proposition ; retenu.proposition pour le retenu diff --git a/src/application/libelles.test.js b/src/application/libelles.test.js index 63794ff..63341ed 100644 --- a/src/application/libelles.test.js +++ b/src/application/libelles.test.js @@ -1245,8 +1245,11 @@ describe("libellés : l'ordre des priorités et le mode des objectifs (§ 5.7, assert.equal(libelle('POIDS_HORS_DOMAINE', { valeur: 2.5, remede: null }), "Un poids est un nombre entier de 1 à 100 : 2,5 ne l'est pas."); assert.equal(libelle('POIDS_HORS_DOMAINE', { valeur: Number.NaN, remede: null }), 'Un poids est un nombre entier de 1 à 100.'); const partage = libelle('OBJECTIFS_PALIER_PARTAGE', { mode: 'minimum', palier: 1, remede: null }); - for (const motif of [/« le minimum, puis le total »/, /deux valeurs l'une après l'autre/, /« tours hors objectif »/, /palier 2\b/, /séparer/i, /reste celui d'avant/]) { + for (const motif of [/^Ce mode compare deux valeurs l'une après l'autre/, /« tours hors objectif »/, /palier 2\b/, /séparer/i, /reste celui d'avant/]) { assert.match(partage, motif); } + // Posé contre le groupe du mode, il ne nomme pas le mode autrement que + // le bouton choisi. + assert.doesNotMatch(partage, /minimum|total/); }); }); diff --git a/src/application/libelles/mobilier.js b/src/application/libelles/mobilier.js index a1f6d10..155df15 100644 --- a/src/application/libelles/mobilier.js +++ b/src/application/libelles/mobilier.js @@ -192,12 +192,19 @@ export const TABLE = new Map([ ({ valeur }) => `Un poids est un nombre entier de 1 à ${POIDS_MAX}` + (Number.isFinite(valeur) ? ` : ${nombreEcrit(valeur)} ne l'est pas.` : '.'), ], + // Le refus se pose contre le groupe du mode, sous le libellé du bouton + // choisi : il dit « ce mode » sans le renommer, l'écran et le journal ne + // le nommant pas pareil (D22). Un mode inconnu lève. [ 'OBJECTIFS_PALIER_PARTAGE', - ({ mode, palier }, texte) => - `Le mode ${cite(nomMode(mode))} compare deux valeurs l'une après l'autre : les ` + - `${cite(texte('mobilier.critere.toursHorsObjectif'))} s'y rangent seuls à leur palier, et partagent le palier ` + - `${numeroDePalier(palier)}. Les séparer d'abord dans l'ordre des priorités : le mode reste celui d'avant.`, + ({ mode, palier }, texte) => { + nomMode(mode); + return ( + `Ce mode compare deux valeurs l'une après l'autre : les ` + + `${cite(texte('mobilier.critere.toursHorsObjectif'))} s'y rangent seuls à leur palier, et partagent le palier ` + + `${numeroDePalier(palier)}. Les séparer d'abord dans l'ordre des priorités : le mode reste celui d'avant.` + ); + }, ], // Le nom complet de chaque critère de l'ordre, tel que l'éditeur l'écrit // (§ 5.7) : la phrase de l'ordre nomme, elle, la valeur qui classe diff --git a/src/application/objectifs.js b/src/application/objectifs.js index 4fffbd6..6f6d78b 100644 --- a/src/application/objectifs.js +++ b/src/application/objectifs.js @@ -16,6 +16,11 @@ import { regleObjectifs } from '../stockage/document.js'; import { lireEtiquettes } from '../stockage/etiquettes.js'; import { refus } from './erreurs.js'; +// La règle par défaut des objectifs de l'événement, que le stockage seul lit +// (« ou » quand les réglages n'en portent pas) : l'interface, qui n'importe +// pas le stockage, la lit ici. +export { regleObjectifs }; + // Deux chaînes comparées unité UTF-16 par unité : l'ordre où le fichier // d'état range une liste d'étiquettes (§ 8.9). const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0); diff --git a/src/interface/diagnostic/Diagnostic.navigateur.test.js b/src/interface/diagnostic/Diagnostic.navigateur.test.js index 9059c28..c4a51cc 100644 --- a/src/interface/diagnostic/Diagnostic.navigateur.test.js +++ b/src/interface/diagnostic/Diagnostic.navigateur.test.js @@ -281,6 +281,70 @@ describe('la grande démonstration (§ 15.1, § 19.3 capture 10)', () => { }); }); +describe('les planchers des objectifs (§ 5.6, A10, D16, D20)', () => { + // Quarante personnes inventées qui portent « finance », une table de 8 qui + // la vise pour la soirée, quatre tables de 8 sans objectif, quatre tours : + // les planchers valent 128, 8 et 4 (A10). mode : celui des objectifs, + // absent au défaut. + function quaranteFinance(mode = null) { + const charge = creerCharge({ id: 'evt-objectifs', nom: 'Soirée des Merles', siegesParDefaut: 8, tours: 4 }); + charge.participants = Array.from({ length: 40 }, (_, p) => ({ + id: p + 1, + nom: `Lacasse${p + 1}`, + prenom: 'Ondine', + appartenance: null, + courriel: null, + titrePressenti: null, + notes: null, + exclu: false, + etiquettes: ['finance'], + })); + charge.tables = [1, 2, 3, 4, 5].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } })); + charge.tables[0].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }]; + if (mode !== null) charge.reglages.modeObjectifs = mode; + charge.prochainsIds = { participant: 41, table: 6, proposition: 1 }; + return charge; + } + + // Les énoncés des objectifs rendus sous la racine : la valeur que porte + // data-plancher-objectifs, et la phrase. + const planchers = (racine) => + [...racine.querySelectorAll('[data-plancher-objectifs]')].map((li) => [li.dataset.plancherObjectifs, texteDe(li.querySelector('[data-phrase]'))]); + + test('sous « le plus de places servies », le seul plancher des tours hors objectif, avec son remède, dans la section et le panneau du plan', async () => { + const m = await ouvrirCharge(quaranteFinance()); + m.ui.allerA('diagnostic'); + expect(diagnostiquer(configurationDepuisCharge(charge(m))).objectifsInevitables).toEqual({ horsObjectif: 128, jamaisServies: 8, horsObjectifMax: 4 }); + await attendre(() => + expect(planchers(section(m))).toEqual([['toursHorsObjectif', t('diagnostic.objectifs.toursHorsObjectif', { plancher: 128 })]]), + ); + const ligne = section(m).querySelector('[data-plancher-objectifs]'); + expect(ligne.dataset.enonce).toBe('diagnostic.objectifs.toursHorsObjectif'); + expect(texteDe(ligne.querySelector('[data-remede]'))).toBe(t('diagnostic.remede.objectifs')); + expect(visible(ligne.querySelector('[data-phrase]'))).toBe(true); + m.ui.allerA('plan'); + await attendre(() => expect(planchers(panneau(m)).map(([grandeur]) => grandeur)).toEqual(['toursHorsObjectif'])); + }); + + test('sous « chacun au moins une fois », les personnes jamais servies d’abord, puis les tours hors objectif', async () => { + const autre = await ouvrirCharge(quaranteFinance('chacun')); + autre.ui.allerA('diagnostic'); + await attendre(() => + expect(planchers(section(autre))).toEqual([ + ['jamaisServies', t('diagnostic.objectifs.jamaisServies', { plancher: 8 })], + ['toursHorsObjectif', t('diagnostic.objectifs.toursHorsObjectif', { plancher: 128 })], + ]), + ); + }); + + test('sans objectif, aucun plancher des objectifs ne s’écrit', async () => { + const m = await demonstration('petite-conflit'); + m.ui.allerA('diagnostic'); + await attendre(() => expect(section(m).querySelectorAll('[data-enonce]').length).toBeGreaterThan(0)); + expect(section(m).querySelectorAll('[data-plancher-objectifs]')).toHaveLength(0); + }); +}); + describe('une seule appartenance (§ 15.4)', () => { test('la phrase et son remède ; en lecture, refusé avec « Modifier » ; en écriture, exécuté en une entrée', async () => { const m = await ouvrirCharge(chargeDouze(() => 'Club des Merles')); diff --git a/src/interface/diagnostic/Diagnostic.svelte b/src/interface/diagnostic/Diagnostic.svelte index ba0d44a..ae98f99 100644 --- a/src/interface/diagnostic/Diagnostic.svelte +++ b/src/interface/diagnostic/Diagnostic.svelte @@ -25,6 +25,11 @@ // qui l'exécute par la séance ; un refus de la séance — mode lecture, plan // bloqué — s'affiche à côté du bouton, avec son propre remède. // + // Les planchers des objectifs (A10) s'énoncent selon le mode réglé : ils + // lisent l'état des critères, criteresDuDiagnostic, qui ne s'évalue que + // lorsqu'un plancher des objectifs est positif. Chacun porte + // data-plancher-objectifs, la valeur dont il donne le plancher. + // // Une configuration que le moteur refuse n'a pas de diagnostic : son // code, traduit, se pose à sa place, jamais le message de l'erreur. import { getContext } from 'svelte'; @@ -35,7 +40,7 @@ import Commande from '../primitives/Commande.svelte'; import Pastille from '../primitives/Pastille.svelte'; import Refus from '../primitives/Refus.svelte'; - import { cartesDiagnostic, enonces, numeroDeCharge } from './phrases.js'; + import { cartesDiagnostic, criteresDuDiagnostic, enonces, numeroDeCharge } from './phrases.js'; let { compact = false } = $props(); @@ -49,7 +54,11 @@ const liste = $derived( resultat === null || resultat.diagnostic === null ? [] - : enonces(resultat.diagnostic, { numeroDe: numeroDeCharge(charge), tours: charge.evenement.tours }), + : enonces(resultat.diagnostic, { + numeroDe: numeroDeCharge(charge), + tours: charge.evenement.tours, + criteres: criteresDuDiagnostic(charge, resultat.diagnostic), + }), ); const cartes = $derived(cartesDiagnostic(liste)); // Le rang de teinte de chaque appartenance, sur tous les participants. @@ -72,7 +81,7 @@
    {#each carte.enonces as enonce, n (n)} -
  • +
  • {t(enonce.cle, enonce.details)}

    {#if !compact && enonce.remede !== null} {@const action = actionDe(enonce.remede)} diff --git a/src/interface/diagnostic/phrases.js b/src/interface/diagnostic/phrases.js index 66eb457..42b92a3 100644 --- a/src/interface/diagnostic/phrases.js +++ b/src/interface/diagnostic/phrases.js @@ -16,11 +16,19 @@ // un texte. // // L'ordre est celui du § 5.6 — collisions et excédent, redondance, -// animateurs, ancrage, écart d'itinéraire, manque de places —, précédé de ce -// qui vide la séparation de son sens (§ 15.4, § 7.5) et suivi du plafond a -// priori (§ 5.5) et des retours imposés (§ 5.4). Un plancher ne s'énonce que -// strictement positif : un plancher nul laisserait croire qu'un plancher a -// été calculé là où il n'y en a pas. +// animateurs, ancrage, écart d'itinéraire, manque de places, objectifs hors +// d'atteinte —, précédé de ce qui vide la séparation de son sens (§ 15.4, +// § 7.5) et suivi du plafond a priori (§ 5.5) et des retours imposés +// (§ 5.4). Un plancher ne s'énonce que strictement positif : un plancher nul +// laisserait croire qu'un plancher a été calculé là où il n'y en a pas. +// +// Les planchers des objectifs (A10, D16) se calculent toujours ; l'énoncé +// n'écrit que ceux des valeurs que le mode réglé compare, lues dans l'état +// du critère des tours hors objectif (les valeurs de valeursQuiClassent, +// telles qu'evaluer les rend), dans l'ordre où le classement les compare. +// Un critère décoché ou sans objet ne compare rien : ses planchers se +// taisent. +import { evaluer } from '../../application/evaluation.js'; // Désactiver la séparation : le seul remède que réalise une commande. Un // objet neuf à chaque appel. @@ -127,6 +135,51 @@ function placesManquantes({ placesManquantes: manquantes }) { return [{ cle: 'diagnostic.placesManquantes', details: { placesManquantes: manquantes }, remede: remede('diagnostic.remede.places') }]; } +// Le nom du critère des objectifs dans l'état des critères, et, pour chaque +// valeur qu'il peut comparer, le champ de objectifsInevitables qui en donne +// le plancher. +const CRITERE_DES_OBJECTIFS = 'toursHorsObjectif'; +const PLANCHERS_DES_OBJECTIFS = new Map([ + ['toursHorsObjectif', 'horsObjectif'], + ['jamaisServies', 'jamaisServies'], + ['horsObjectifMax', 'horsObjectifMax'], +]); + +// Vrai quand un plancher des objectifs au moins est strictement positif. +const unPlancherDesObjectifs = ({ objectifsInevitables }) => + [...PLANCHERS_DES_OBJECTIFS.values()].some((champ) => objectifsInevitables[champ] > 0); + +// Le plancher de chaque valeur que le critère des objectifs compare, quand +// il est strictement positif, dans l'ordre de ses valeurs ; rien quand le +// critère manque à criteres, est décoché ou sans objet. grandeur nomme la +// valeur, que l'écran pose en data-plancher-objectifs. +function objectifs({ objectifsInevitables }, criteres) { + const etat = criteres.find(({ nom }) => nom === CRITERE_DES_OBJECTIFS); + if (etat === undefined || !etat.coche || etat.sansObjet) return []; + return etat.valeurs.flatMap((grandeur) => { + if (!PLANCHERS_DES_OBJECTIFS.has(grandeur)) throw new RangeError(`valeur des objectifs inconnue : ${JSON.stringify(grandeur)}`); + const plancher = objectifsInevitables[PLANCHERS_DES_OBJECTIFS.get(grandeur)]; + if (plancher === 0) return []; + return [{ cle: `diagnostic.objectifs.${grandeur}`, details: { plancher }, remede: remede('diagnostic.remede.objectifs'), grandeur }]; + }); +} + +/** + * L'état des critères que l'énoncé des objectifs lit (§ 5.3, D10, D20) : + * celui qu'evaluer rend, sur la charge sans ses placements — aucune + * proposition ne se mesure pour le lire. Quand aucun plancher des objectifs + * n'est positif, il n'y a rien à lire : la liste vide, sans évaluation. + * Ne modifie pas la charge. + * + * @param {Object} charge + * @param {Object} diagnostic celui de diagnostiquerCharge pour cette charge + * @returns {Array<{nom: string, coche: boolean, sansObjet: boolean, valeurs: string[]}>} + */ +export function criteresDuDiagnostic(charge, diagnostic) { + if (!unPlancherDesObjectifs(diagnostic)) return []; + return evaluer({ ...charge, propositions: [], retenu: null }, { plafondsAPriori: null }).criteres; +} + // Le plafond a priori, une ligne par valeur de la répartition ; une // population vide n'en a aucun, et l'énoncé le dit par le tiret. function plafond({ plafondAPriori }) { @@ -155,13 +208,16 @@ function imposes({ retoursImposes, ancragesImposantRetours }) { * le diagnostic. * * @param {Object} diagnostic celui que rend diagnostiquerCharge - * @param {{numeroDe: (table: number) => number, tours: number}} options + * @param {{numeroDe: (table: number) => number, tours: number, + * criteres?: Array<{nom: string, coche: boolean, sansObjet: boolean, valeurs: string[]}>}} options * numeroDe : le numéro affiché d'un identifiant de table ; tours : le - * nombre de tours de l'événement + * nombre de tours de l'événement ; criteres : l'état des critères + * (criteresDuDiagnostic), vide par défaut, et aucun plancher des + * objectifs ne s'écrit alors * @returns {Array<{cle: string, details: Object, remede: null|{cle: string, details: Object, - * commande?: string, arguments?: Object}}>} + * commande?: string, arguments?: Object}, grandeur?: string}>} */ -export function enonces(diagnostic, { numeroDe, tours }) { +export function enonces(diagnostic, { numeroDe, tours, criteres = [] }) { return [ ...separation(diagnostic), ...collisions(diagnostic, tours), @@ -170,6 +226,7 @@ export function enonces(diagnostic, { numeroDe, tours }) { ...ancrage(diagnostic, numeroDe), ...ecartItineraire(diagnostic), ...placesManquantes(diagnostic), + ...objectifs(diagnostic, criteres), ...plafond(diagnostic), ...imposes(diagnostic), ]; diff --git a/src/interface/diagnostic/phrases.test.js b/src/interface/diagnostic/phrases.test.js index 10d61bc..fa6d2f0 100644 --- a/src/interface/diagnostic/phrases.test.js +++ b/src/interface/diagnostic/phrases.test.js @@ -16,7 +16,7 @@ import { texteLivre } from '../../demo/livrees.js'; import { diagnostiquer } from '../../moteur/diagnostic.js'; import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js'; import { TABLES_INTERFACE } from '../libelles.js'; -import { cartesDiagnostic, enonces, numeroDeCharge } from './phrases.js'; +import { cartesDiagnostic, criteresDuDiagnostic, enonces, numeroDeCharge } from './phrases.js'; const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); const TIRET = '\u{2014}'; @@ -58,11 +58,28 @@ function chargeDe({ appartenances, capacites, tours = 4, ancres = [], separer = const fois = (n, valeur) => Array(n).fill(valeur); -// Le diagnostic d'une charge, par l'application, et ses énoncés. +/** + * Une charge d'objectifs (§ 4.5, A10) : n personnes qui portent + * « finance », une table 1 de 8 places qui la vise pour la soirée, puis des + * tables sans objectif aux capacités données, quatre tours. mode : le mode + * des objectifs, absent au défaut ; respecter faux décoche le critère. + */ +function chargeObjectifs(n, autres, { mode = null, respecter = true } = {}) { + const charge = chargeDe({ appartenances: fois(n, null), capacites: [8, ...autres] }); + for (const participant of charge.participants) participant.etiquettes = ['finance']; + charge.tables[0].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }]; + if (mode !== null) charge.reglages.modeObjectifs = mode; + if (!respecter) charge.reglages.respecterObjectifs = false; + return charge; +} + +// Le diagnostic d'une charge, par l'application, et ses énoncés, sous l'état +// des critères que l'écran lit pour les objectifs. function lire(charge) { const { diagnostic, erreur } = diagnostiquerCharge(charge); assert.equal(erreur, null); - return { diagnostic, liste: enonces(diagnostic, { numeroDe: numeroDeCharge(charge), tours: charge.evenement.tours }) }; + const criteres = criteresDuDiagnostic(charge, diagnostic); + return { diagnostic, liste: enonces(diagnostic, { numeroDe: numeroDeCharge(charge), tours: charge.evenement.tours, criteres }) }; } const cles = (liste) => liste.map(({ cle }) => cle); @@ -120,6 +137,9 @@ const CONFIGURATIONS = { petiteConflit: () => chargeDemo('petite-conflit'), grande: () => chargeDemo('grande'), grandeSansException: () => chargeDemo('grande-sans-exception'), + // Dix personnes « finance », une table « finance » de 8, une table de 2 + // sans objectif, quatre tours (A10). + objectifs: () => chargeObjectifs(10, [2]), }; // --- Chaque champ, son énoncé ------------------------------------------------------ @@ -239,6 +259,22 @@ const CHAMPS = [ ] : [], }, + { + // Sous le mode par défaut, « le plus de places servies », le seul + // plancher que le mode compare : les tours hors objectif. + champ: 'objectifsInevitables', + attendus: (d) => + d.objectifsInevitables.horsObjectif > 0 + ? [ + { + cle: 'diagnostic.objectifs.toursHorsObjectif', + details: { plancher: d.objectifsInevitables.horsObjectif }, + remede: { cle: 'diagnostic.remede.objectifs', details: {} }, + grandeur: 'toursHorsObjectif', + }, + ] + : [], + }, { champ: 'plafondAPriori', attendus: (d) => @@ -457,6 +493,83 @@ describe('enonces : les phrases du § 5.6', () => { }); }); +describe('enonces : les planchers des objectifs (§ 5.6, A10, D16, D20)', () => { + // Les énoncés des objectifs d'une liste : grandeur, plancher et texte. + const desObjectifs = (liste) => + liste.filter(({ cle }) => cle.startsWith('diagnostic.objectifs.')).map((enonce) => [enonce.grandeur, enonce.details.plancher, texte(enonce)]); + + test('dix personnes « finance », une table de 8, quatre tours : 8 tours hors objectif ; les deux autres planchers, nuls, se taisent sous chaque mode', () => { + const { diagnostic, liste } = lire(chargeObjectifs(10, [2])); + assert.deepEqual(diagnostic.objectifsInevitables, { horsObjectif: 8, jamaisServies: 0, horsObjectifMax: 1 }); + assert.deepEqual(desObjectifs(liste), [ + [ + 'toursHorsObjectif', + 8, + "au moins 8 tours hors objectif : les tables qui portent un objectif n'ont pas assez de places libres pour chaque tour possible", + ], + ]); + // Sous « chacun au moins une fois », le plancher des personnes jamais + // servies, nul, ne s'écrit pas. + assert.deepEqual(desObjectifs(lire(chargeObjectifs(10, [2], { mode: 'chacun' })).liste).map(([grandeur]) => grandeur), ['toursHorsObjectif']); + // Sous « l'équité d'abord », le plus de tours hors objectif d'une + // personne vaut 1 : il s'écrit, avant les tours hors objectif. + assert.deepEqual(desObjectifs(lire(chargeObjectifs(10, [2], { mode: 'minimum' })).liste), [ + ['horsObjectifMax', 1, 'au moins une personne passe au moins 1 tour hors objectif, quel que soit le placement'], + [ + 'toursHorsObjectif', + 8, + "au moins 8 tours hors objectif : les tables qui portent un objectif n'ont pas assez de places libres pour chaque tour possible", + ], + ]); + }); + + test('quarante personnes « finance » : 128, 8 et 4 ; chaque mode écrit les planchers qu’il compare, dans l’ordre où il les compare, et jamais les autres', () => { + const autres = fois(4, 8); + const { diagnostic, liste } = lire(chargeObjectifs(40, autres)); + assert.deepEqual(diagnostic.objectifsInevitables, { horsObjectif: 128, jamaisServies: 8, horsObjectifMax: 4 }); + const grandeurs = (mode) => desObjectifs(lire(chargeObjectifs(40, autres, { mode })).liste).map(([grandeur, plancher]) => [grandeur, plancher]); + assert.deepEqual(desObjectifs(liste).map(([grandeur, plancher]) => [grandeur, plancher]), [['toursHorsObjectif', 128]]); + assert.deepEqual(grandeurs('chacun'), [ + ['jamaisServies', 8], + ['toursHorsObjectif', 128], + ]); + assert.deepEqual(grandeurs('minimum'), [ + ['horsObjectifMax', 4], + ['toursHorsObjectif', 128], + ]); + const [jamais] = desObjectifs(lire(chargeObjectifs(40, autres, { mode: 'chacun' })).liste); + assert.equal(jamais[2], 'au moins 8 personnes jamais servies par une table à objectif, quel que soit le placement'); + for (const enonce of liste.filter(({ cle }) => cle.startsWith('diagnostic.objectifs.'))) { + assert.deepEqual(enonce.remede, { cle: 'diagnostic.remede.objectifs', details: {} }); + assert.equal(typeof texte(enonce.remede), 'string'); + } + }); + + test('le critère des objectifs décoché : aucun plancher des objectifs, le diagnostic les calcule toujours', () => { + const { diagnostic, liste } = lire(chargeObjectifs(40, fois(4, 8), { mode: 'chacun', respecter: false })); + assert.equal(diagnostic.objectifsInevitables.horsObjectif, 128); + assert.deepEqual(desObjectifs(liste), []); + }); + + test('criteresDuDiagnostic : sans plancher positif, aucune lecture des critères ; avec, l’état de chaque critère du registre', () => { + const sans = CONFIGURATIONS.petite(); + assert.deepEqual(criteresDuDiagnostic(sans, diagnostiquerCharge(sans).diagnostic), []); + const charge = chargeObjectifs(40, fois(4, 8), { mode: 'minimum' }); + const avant = structuredClone(charge); + const criteres = criteresDuDiagnostic(charge, diagnostiquerCharge(charge).diagnostic); + const objectifs = criteres.find(({ nom }) => nom === 'toursHorsObjectif'); + assert.deepEqual(objectifs, { nom: 'toursHorsObjectif', coche: true, sansObjet: false, valeurs: ['horsObjectifMax', 'toursHorsObjectif'] }); + assert.deepEqual(charge, avant); + }); + + test('sans état des critères, enonces n’écrit aucun plancher des objectifs', () => { + const charge = chargeObjectifs(40, fois(4, 8)); + const { diagnostic } = diagnostiquerCharge(charge); + const liste = enonces(diagnostic, { numeroDe: numeroDeCharge(charge), tours: 4 }); + assert.deepEqual(desObjectifs(liste), []); + }); +}); + describe('enonces : aux bords', () => { test('un événement neuf, zéro participant, zéro table : aucun plancher, le plafond a priori « — », jamais 0', () => { const { liste } = lire(chargeDe({ appartenances: [], capacites: [] })); diff --git a/src/interface/generation/Evaluation.navigateur.test.js b/src/interface/generation/Evaluation.navigateur.test.js index f915d5f..d77052d 100644 --- a/src/interface/generation/Evaluation.navigateur.test.js +++ b/src/interface/generation/Evaluation.navigateur.test.js @@ -5,7 +5,9 @@ // moteur de rendu (§ 5.7, § 13.3) : tant que la charge de la séance ne // change pas, revenir à l'une ou l'autre section ne mesure rien de nouveau ; // une charge écrite se mesure une fois, et la meilleure proposition annoncée -// la suit. +// la suit. Une charge sans proposition n'a pas de table partagée : l'éditeur +// des priorités y lit l'état des critères par une évaluation à lui, une fois +// par charge écrite. // // Le module de l'évaluation est remplacé par ses propres fonctions, // espionnées (vi.mock, spy) : evaluer garde son effet, et ses appels se @@ -109,8 +111,14 @@ describe('Génération et Propositions relisent une même évaluation (§ 5.7, const anciens = charge(m).propositions.map(({ id }) => id); const avantEcriture = appels(); await m.seance.executer('effacerPropositions', {}); + flushSync(); + expect(appels(), 'la charge sans proposition, évaluée une fois pour l’état des critères').toBe(avantEcriture + 1); + aller(m, 'participants'); + aller(m, 'generation'); + expect(appels(), 'la charge sans proposition, revisitée').toBe(avantEcriture + 1); + const avantGeneration = appels(); await enregistrer(m, { graine: 9, nombre: 2, arret: 300 }); - expect(appels(), 'la charge écrite, mesurée une fois').toBe(avantEcriture + 1); + expect(appels(), 'la charge écrite, mesurée une fois').toBe(avantGeneration + 1); const attendu = rangUnAttendu(m); const apres = appels(); expect(identifiant(m).textContent).toBe(attendu); diff --git a/src/interface/generation/Generation.navigateur.test.js b/src/interface/generation/Generation.navigateur.test.js index dc2d453..22c85e4 100644 --- a/src/interface/generation/Generation.navigateur.test.js +++ b/src/interface/generation/Generation.navigateur.test.js @@ -40,6 +40,7 @@ import { fixerCadre, pointer, touche } from '../../../test/navigateur/entrees.js import { monterApp } from '../../../test/navigateur/montage.js'; import { composantes, contraste, sousTheme } from '../../../test/navigateur/rendu.js'; import { TABLES_INTERFACE } from '../libelles.js'; +import { REGLAGES_GENERATION, valeurDuReglage } from './reglages.js'; import Travailleur from './travailleur.js?worker&inline'; const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); @@ -295,7 +296,7 @@ async function regler(m, reglages) { describe('les réglages (§ 5.7, § 5.10)', () => { test('exécuteur direct : quatre champs, chacun son libellé et son unité ; l’historique pré-rempli à 1 000 ; aucun champ de durée', async () => { const m = await demonstration('petite', { executeur: creerExecuteurDirect() }); - const entrees = [...section(m).querySelectorAll('input:not([type="checkbox"])')]; + const entrees = [...section(m).querySelectorAll('input:not([type="checkbox"]):not([type="radio"])')]; expect(entrees.length).toBe(4); expect(entrees.map((entree) => entree.dataset.champ)).toEqual(CHAMPS); for (const cle of CHAMPS) { @@ -386,24 +387,36 @@ describe('les réglages (§ 5.7, § 5.10)', () => { }); describe('les contraintes et l’attribution des sièges (§ 5.3)', () => { - // Les cinq bascules, dans l'ordre du § 5.3. - const BASCULES = ['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable', 'varierAppartenances', 'attribuerSieges']; + // Les sept bascules, dans l'ordre du § 5.3 ; les deux cases facultatives + // valent leur défaut tant que la charge ne les porte pas. + const BASCULES = [ + 'separerAppartenances', + 'nouveauxVoisins', + 'nouvelleTable', + 'varierAppartenances', + 'respecterObjectifs', + 'rencontrerAnimateurs', + 'attribuerSieges', + ]; const NOMS_DU_SPEC = [ 'Séparer les appartenances', 'Nouveaux voisins à chaque tour', 'Nouvelle table à chaque tour', 'Varier les appartenances rencontrées', + 'Respecter les objectifs des tables', + "Rencontrer le plus d'animateurs différents", 'Attribuer les sièges', ]; + const valeurLue = (reglages, cle) => valeurDuReglage(reglages, REGLAGES_GENERATION.find((entree) => entree.cle === cle)); - test('direct : cinq bascules sous leur nom du § 5.3 ; chacune, une entrée, son réglage seul', async () => { + test('direct : sept bascules sous leur nom du § 5.3 ; chacune, une entrée, son réglage seul', async () => { const m = await demonstration('petite', { executeur: creerExecuteurDirect() }); const cases = [...section(m).querySelectorAll('input[type="checkbox"]')]; expect(cases.map((caseACocher) => caseACocher.dataset.champ)).toEqual([...BASCULES, 'coeursAutomatiques']); BASCULES.forEach((cle, rang) => { expect(etiquette(m, cle).textContent).toBe(t(`generation.contrainte.${cle}`)); expect(t(`generation.contrainte.${cle}`)).toBe(NOMS_DU_SPEC[rang]); - expect(bascule(m, cle).checked, cle).toBe(charge(m).reglages[cle]); + expect(bascule(m, cle).checked, cle).toBe(valeurLue(charge(m).reglages, cle)); }); for (const cle of BASCULES) { const avant = structuredClone(charge(m).reglages); @@ -412,8 +425,8 @@ describe('les contraintes et l’attribution des sièges (§ 5.3)', () => { await cliquer(m, bascule(m, cle)); expect(revision(m), cle).toBe(r0 + 1); expect(journalDe(m).entrees.length, cle).toBe(entrees + 1); - expect(charge(m).reglages, cle).toEqual({ ...avant, [cle]: !avant[cle] }); - expect(bascule(m, cle).checked, cle).toBe(!avant[cle]); + expect(charge(m).reglages, cle).toEqual({ ...avant, [cle]: !valeurLue(avant, cle) }); + expect(bascule(m, cle).checked, cle).toBe(!valeurLue(avant, cle)); } // La barre d'espace bascule aussi, par le même geste. const cle = BASCULES[0]; diff --git a/src/interface/generation/Priorites.svelte b/src/interface/generation/Priorites.svelte new file mode 100644 index 0000000..1c40578 --- /dev/null +++ b/src/interface/generation/Priorites.svelte @@ -0,0 +1,257 @@ + + + + +
    + {t(entree.etiquette)} +

    {t('generation.priorites.note')}

    +

    {t(regle ? 'generation.priorites.regle' : 'generation.priorites.parDefaut')}

    +
      + {#each liste as palier, index (palier.map(({ critere }) => critere).join())} + {@const partage = palier.length > 1} +
    1. +

      {t('generation.priorites.palier', { palier: index + 1, paliers: liste.length })}

      +
        + {#each palier as { critere, poids } (critere)} + {@const etat = etatAffiche(etats?.get(critere))} +
      • + {t(`mobilier.critere.${critere}`)} + {#if etat !== null} + {t(`generation.priorites.etat.${etat}`)} + {/if} + {#if partage} + + {/if} +
        + {#each gestesOfferts(liste, critere, admet) as geste (geste)} + deplacer(critere, geste)} + /> + {/each} +
        +
      • + {/each} +
      +
    2. + {/each} +
    +

    {annonce}

    +
    + +
    + {#if refus !== null} + (refus = null)} /> + {/if} +
    + + diff --git a/src/interface/generation/Reglages.navigateur.test.js b/src/interface/generation/Reglages.navigateur.test.js index 9df02c9..5389bf5 100644 --- a/src/interface/generation/Reglages.navigateur.test.js +++ b/src/interface/generation/Reglages.navigateur.test.js @@ -8,9 +8,16 @@ // entrée de la section 'generation' de REGLAGES_GENERATION dans son ordre, // sous son libellé, à la valeur de la charge, et aucune d'une autre section ; // une définition passée par l'épreuve, une entrée ajoutée ou retirée, change -// l'écran de même. Le texte attendu vient de la -// même traduction que celle de l'application ; les noms d'épreuve sont -// inventés. +// l'écran de même. +// +// L'éditeur de l'ordre des priorités et le choix du mode des objectifs +// (§ 5.3, § 5.7, A8, A11, A12, D23) s'éprouvent dans l'application montée +// sur la petite démonstration, la section de la génération ouverte : chaque +// geste, au pointeur réel ou au clavier, est une commande et une entrée ; les +// gestes offerts sont ceux du modèle sous l'admission de la commande ; un +// refus se pose contre la liste ou contre le groupe, et le choix refusé +// revient à l'ancien mode. Le texte attendu vient de la même traduction que +// celle de l'application ; les noms d'épreuve sont inventés. // // Le projet navigateur emploie expect, et test et describe de Vitest // (§ 14.8). @@ -22,12 +29,18 @@ import { creerHorlogePilotee } from '../../application/horloge.js'; import { TABLES_APPLICATION } from '../../application/libelles.js'; import { creerSeance } from '../../application/seance.js'; import { creerTraduction } from '../../application/traduction.js'; +import { CRITERES, MODES_OBJECTIFS } from '../../moteur/criteres.js'; +import { lireJournal } from '../../stockage/journal.js'; +import { SUFFIXES } from '../../stockage/noms.js'; import { VERSION } from '../../version.genere.js'; import { creerFichiersSimules } from '../../../test/fichiers_simules.js'; -import { couleurDeJeton } from '../../../test/navigateur/captures.js'; +import { couleurDeJeton, releverTextes } from '../../../test/navigateur/captures.js'; +import { fixerCadre, pointer, touche } from '../../../test/navigateur/entrees.js'; +import { monterApp } from '../../../test/navigateur/montage.js'; import { composantes, sousTheme } from '../../../test/navigateur/rendu.js'; import { TABLES_INTERFACE } from '../libelles.js'; import Reglages from './Reglages.svelte'; +import { LISTE_PAR_DEFAUT, admissionSous, etatAffiche, etatsDesCriteres, gestesOfferts, listeApres } from './priorites.js'; import { REGLAGES_GENERATION, reglagesDeSection } from './reglages.js'; // Les entrées que l'écran de la génération montre : celles de sa section. @@ -100,6 +113,18 @@ async function monter({ avecCharge = (charge) => charge, props = {} } = {}) { // Les désignations des champs montrés, dans l'ordre du document. const designations = (cible) => [...cible.querySelectorAll('input[data-champ]')].map((entree) => entree.dataset.champ); +// Les désignations qu'une définition montre, dans l'ordre du document : les +// champs, puis chaque bascule suivie des boutons radio des choix qui se +// montrent sous elle ; l'ordre des priorités, à son défaut, n'a aucun champ. +function designationsAttendues(definition) { + const generation = reglagesDeSection(definition, 'generation'); + const sous = (cle) => generation.filter((e) => e.genre === 'choix' && e.sous === cle).flatMap((e) => e.valeurs.map(() => e.cle)); + return [ + ...generation.filter(({ genre }) => genre === 'champ').map(({ cle }) => cle), + ...generation.filter(({ genre }) => genre === 'bascule').flatMap(({ cle }) => [cle, ...sous(cle)]), + ]; +} + // L'entrée désignée par sa clé, et le texte de son libellé. const entree = (cible, cle) => cible.querySelector(`input[data-champ="${cle}"]`); const libelleDe = (cible, cle) => cible.querySelector(`label[for="${entree(cible, cle).id}"]`).textContent.trim(); @@ -109,9 +134,26 @@ describe('Reglages : l’écran montre la définition (§ 19.8)', () => { const cible = await monter(); expect(DE_LA_GENERATION.length).toBeGreaterThan(0); expect(DE_LA_GENERATION.length).toBeLessThan(REGLAGES_GENERATION.length); - expect(designations(cible)).toEqual(['graine', ...DE_LA_GENERATION.map(({ cle }) => cle), 'coeursAutomatiques']); + expect(designations(cible)).toEqual(['graine', ...designationsAttendues(REGLAGES_GENERATION), 'coeursAutomatiques']); expect(cible.querySelector('[data-champ="cibleRedondance"]')).toBeNull(); - for (const { cle, etiquette, genre, defaut } of DE_LA_GENERATION) { + for (const { cle, etiquette, genre, defaut, valeurs } of DE_LA_GENERATION) { + if (genre === 'choix') { + const groupe = cible.querySelector('[data-mode-objectifs]'); + expect(groupe.querySelector('legend').textContent.trim(), cle).toBe(t(etiquette)); + const radios = [...groupe.querySelectorAll(`input[type="radio"][data-champ="${cle}"]`)]; + expect(radios.map((r) => r.value)).toEqual([...valeurs]); + for (const radio of radios) { + expect(cible.querySelector(`label[for="${radio.id}"]`).textContent.trim()).toBe(t(`${etiquette}.${radio.value}`)); + expect(radio.checked, radio.value).toBe(radio.value === defaut); + } + continue; + } + if (genre === 'ordre') { + const liste = cible.querySelector('[data-priorites]'); + expect(liste.closest('fieldset').querySelector('legend').textContent.trim()).toBe(t(etiquette)); + expect(liste.querySelectorAll('[data-palier]')).toHaveLength(defaut.length); + continue; + } expect(libelleDe(cible, cle), cle).toBe(t(etiquette)); // Une charge neuve porte les défauts de la définition. if (genre === 'bascule') expect(entree(cible, cle).checked, cle).toBe(defaut); @@ -148,8 +190,8 @@ describe('Reglages : l’écran montre la définition (§ 19.8)', () => { }), props: { definition }, }); - const cles = DE_LA_GENERATION.map(({ cle: c }) => c); - expect(designations(cible)).toEqual(['graine', cles[0], 'generation.epreuve', ...cles.slice(1), 'epreuveBascule', 'coeursAutomatiques']); + const [premier, ...autres] = designationsAttendues(REGLAGES_GENERATION); + expect(designations(cible)).toEqual(['graine', premier, 'generation.epreuve', ...autres, 'epreuveBascule', 'coeursAutomatiques']); expect(libelleDe(cible, 'generation.epreuve')).toBe(t('generation.coeurs.titre')); expect(entree(cible, 'generation.epreuve').value).toBe('4321'); expect(libelleDe(cible, 'epreuveBascule')).toBe(t('generation.contraintes.titre')); @@ -179,8 +221,342 @@ describe('l’habillage des réglages', () => { } }); } - const legendes = [...cible.querySelectorAll('legend')]; - expect(legendes.length).toBe(2); + // Les contraintes, l'ordre des priorités et les cœurs : trois groupes ; + // le choix du mode, sous sa case, se lit comme le libellé d'un champ. + const legendes = [...cible.querySelectorAll('fieldset:not([data-mode-objectifs]) > legend')]; + expect(legendes.length).toBe(3); for (const legende of legendes) expect(getComputedStyle(legende).textTransform).toBe('uppercase'); + expect(getComputedStyle(cible.querySelector('[data-mode-objectifs] > legend')).textTransform).toBe('none'); + }); +}); + +// --- L'éditeur des priorités et le choix du mode, dans l'application ---------- + +const [ECART, EXCEDENT, CUMULEES, HORS_OBJECTIF, REPETEES, ANIMATEURS, REDONDANCE] = CRITERES; + +// Les montages de l'application, démontés après l'épreuve ; le collecteur +// d'erreurs n'a rien reçu pendant elle. +const applications = []; +afterEach(() => { + const erreurs = applications.flatMap((m) => m.erreurs()); + for (const m of applications.splice(0).reverse()) m.demonter(); + window.scrollTo(0, 0); + expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]); +}); + +// L'application sur la petite démonstration, en écriture sauf ecriture +// faux, la section de la génération ouverte. +async function demonstration({ ecriture = true } = {}) { + await fixerCadre(1366, 700); + const m = await monterApp({ executeur: creerExecuteurDirect() }); + applications.push(m); + await m.facade.chargerDemonstration('petite'); + if (ecriture) await m.seance.passerEnEcriture(); + m.ui.allerA('generation'); + flushSync(); + return m; +} + +const section = (m) => m.racine.querySelector('[data-section="generation"]'); +const liste = (m) => section(m).querySelector('[data-priorites]'); +const editeur = (m) => liste(m).closest('fieldset'); +const critereDe = (m, nom) => liste(m).querySelector(`[data-priorite="${nom}"]`); +const bouton = (m, nom, geste) => critereDe(m, nom)?.querySelector(`[data-commande="${geste}"]`) ?? null; +const offerts = (m, nom) => [...critereDe(m, nom).querySelectorAll('button[data-commande]')].map((b) => b.dataset.commande); +const annonce = (m) => editeur(m).querySelector(':scope > [role="status"]').textContent.trim(); +const groupe = (m) => section(m).querySelector('[data-mode-objectifs]'); +const radio = (m, valeur) => groupe(m).querySelector(`input[type="radio"][value="${valeur}"]`); +const charge = (m) => m.seance.etat().charge; +const revision = (m) => m.seance.etat().evenement.revision; +const journal = (m) => { + const { base } = m.seance.etat().evenement; + return lireJournal(m.fs.contenu('documents', `${base}${SUFFIXES.journal}`)).entrees.length; +}; +const ordreDe = (m) => charge(m).reglages.priorites ?? structuredClone(LISTE_PAR_DEFAUT); + +// Laisse passer une image, la file de la séance passée, puis le rendu. +async function passer(m) { + await m.seance.formeNommee(null).catch(() => undefined); + await new Promise((resoudre) => requestAnimationFrame(resoudre)); + flushSync(); +} + +// Un clic réel au centre de l'élément, puis la file de la séance passée. +async function cliquer(m, element) { + expect(element, 'élément à cliquer').not.toBeNull(); + element.scrollIntoView({ block: 'center' }); + const { left, top, width, height } = element.getBoundingClientRect(); + await pointer('presser', left + width / 2, top + height / 2); + await pointer('relacher', left + width / 2, top + height / 2); + await passer(m); +} + +// Une touche sur l'élément qui a le focus, puis la file passée. +async function presser(m, cle) { + await touche(cle); + await passer(m); +} + +describe('l’ordre des priorités : l’éditeur (§ 5.7, A11, A12)', () => { + test('au défaut : une liste ordonnée de sept paliers numérotés en texte, chaque critère sous son nom complet, son état lu dans la table d’évaluation, aucun poids', async () => { + const m = await demonstration(); + expect(liste(m).tagName).toBe('OL'); + const paliers = [...liste(m).querySelectorAll(':scope > li[data-palier]')]; + expect(paliers).toHaveLength(7); + paliers.forEach((palier, rang) => { + expect(palier.dataset.palier).toBe(String(rang)); + expect(palier.textContent).toContain(t('generation.priorites.palier', { palier: rang + 1, paliers: 7 })); + }); + const etats = etatsDesCriteres(charge(m)); + for (const nom of CRITERES) { + expect(critereDe(m, nom).textContent, nom).toContain(t(`mobilier.critere.${nom}`)); + const etat = etatAffiche(etats.get(nom)); + for (const ecrit of ['decoche', 'sansObjet']) { + expect(critereDe(m, nom).textContent.includes(t(`generation.priorites.etat.${ecrit}`)), `${nom} ${ecrit}`).toBe(etat === ecrit); + } + } + // La petite démonstration ne porte aucun objectif, et sa case des + // animateurs est décochée. + expect(etatAffiche(etats.get(HORS_OBJECTIF))).toBe('sansObjet'); + expect(etatAffiche(etats.get(ANIMATEURS))).toBe('decoche'); + expect(editeur(m).querySelector('input[data-champ^="poids."]')).toBeNull(); + expect(editeur(m).textContent).toContain(t('generation.priorites.parDefaut')); + expect(annonce(m)).toBe(''); + }); + + test('rouge : les gestes offerts sont ceux du modèle sous l’admission de la commande ; ni « joindre » ni « séparer » pour l’écart et les rencontres répétées, ni « joindre » pour le critère qui les suit', async () => { + const m = await demonstration(); + const admet = admissionSous(charge(m).reglages); + for (const nom of CRITERES) expect(offerts(m, nom), nom).toEqual(gestesOfferts(ordreDe(m), nom, admet)); + for (const nom of [ECART, EXCEDENT, REPETEES, ANIMATEURS]) expect(offerts(m, nom), nom).not.toContain('joindrePalier'); + for (const nom of CRITERES) expect(offerts(m, nom), nom).not.toContain('separerPalier'); + expect(offerts(m, ECART)).toEqual(['descendreCritere']); + expect(offerts(m, REDONDANCE)).toEqual(['monterCritere', 'joindrePalier']); + // Aucun bouton ne nomme une touche. + for (const b of editeur(m).querySelectorAll('button')) expect(b.textContent).not.toMatch(/Alt|Ctrl|Maj|↑|↓|Entrée/); + }); + + test('chaque bouton de l’éditeur a un nom accessible à lui, qui nomme le critère qu’il déplace, au défaut comme dans un palier partagé', async () => { + const m = await demonstration(); + // Le nom accessible d'un bouton sans aria-label ni aria-labelledby est + // son texte. + const noms = () => + [...editeur(m).querySelectorAll('button')].map((b) => { + expect(b.hasAttribute('aria-label') || b.hasAttribute('aria-labelledby')).toBe(false); + return b.textContent.trim(); + }); + const verifier = () => { + const tous = noms(); + expect(new Set(tous).size, tous.join(' | ')).toBe(tous.length); + for (const nom of CRITERES) { + for (const b of critereDe(m, nom).querySelectorAll('button[data-commande]')) { + expect(b.textContent, `${nom} ${b.dataset.commande}`).toContain(t(`mobilier.critere.${nom}`)); + } + } + }; + verifier(); + await cliquer(m, bouton(m, REDONDANCE, 'joindrePalier')); + expect(bouton(m, ANIMATEURS, 'monterCritere')).not.toBeNull(); + verifier(); + }); + + test('un geste au pointeur : une commande, une entrée ; l’annonce dit le palier ; le focus suit le critère déplacé ; un poids par critère du palier partagé', async () => { + const m = await demonstration(); + const [r0, e0] = [revision(m), journal(m)]; + const attendue = listeApres(LISTE_PAR_DEFAUT, 'joindrePalier', REDONDANCE); + await cliquer(m, bouton(m, REDONDANCE, 'joindrePalier')); + expect(revision(m)).toBe(r0 + 1); + expect(journal(m)).toBe(e0 + 1); + expect(charge(m).reglages.priorites).toEqual(attendue); + expect(liste(m).querySelectorAll(':scope > li[data-palier]')).toHaveLength(6); + expect(annonce(m)).toBe(t('generation.priorites.annonce', { critere: REDONDANCE, palier: 6, paliers: 6, avec: [ANIMATEURS] })); + expect(critereDe(m, REDONDANCE).contains(document.activeElement)).toBe(true); + expect(editeur(m).textContent).toContain(t('generation.priorites.regle')); + const poids = [...editeur(m).querySelectorAll('input[data-champ^="poids."]')].map((e) => e.dataset.champ); + expect(poids).toEqual([`poids.${ANIMATEURS}`, `poids.${REDONDANCE}`]); + expect(offerts(m, REDONDANCE)).toEqual(['monterCritere', 'separerPalier']); + }); + + test('au clavier : Entrée sur « Monter », Espace sur « Descendre » ; chaque geste une entrée ; le focus reste sur le bouton du critère déplacé', async () => { + const m = await demonstration(); + const e0 = journal(m); + bouton(m, CUMULEES, 'monterCritere').focus(); + await presser(m, 'Enter'); + expect(charge(m).reglages.priorites).toEqual(listeApres(LISTE_PAR_DEFAUT, 'monterCritere', CUMULEES)); + expect(document.activeElement).toBe(bouton(m, CUMULEES, 'monterCritere')); + bouton(m, CUMULEES, 'descendreCritere').focus(); + await presser(m, ' '); + expect(charge(m).reglages.priorites).toBeUndefined(); + expect(journal(m)).toBe(e0 + 2); + expect(document.activeElement).toBe(bouton(m, CUMULEES, 'descendreCritere')); + expect(annonce(m)).toBe(t('generation.priorites.annonce', { critere: CUMULEES, palier: 3, paliers: 7, avec: [] })); + }); + + test('le poids d’un critère d’un palier partagé : 0 refusé contre son champ, rien d’écrit ; 5 écrit, une entrée', async () => { + const m = await demonstration(); + await cliquer(m, bouton(m, REDONDANCE, 'joindrePalier')); + const champ = () => editeur(m).querySelector(`input[data-champ="poids.${REDONDANCE}"]`); + expect(editeur(m).querySelector(`label[for="${champ().id}"]`).textContent.trim()).toBe(t('generation.priorites.poids', { critere: REDONDANCE })); + const e0 = journal(m); + await cliquer(m, champ()); + champ().select(); + await touche('0'); + await presser(m, 'Tab'); + expect(champ().parentElement.querySelector('[data-refus="POIDS_HORS_DOMAINE"]')).not.toBeNull(); + expect(journal(m)).toBe(e0); + await cliquer(m, champ()); + champ().select(); + await touche('5'); + await presser(m, 'Enter'); + expect(charge(m).reglages.priorites.at(-1)).toEqual([ + { critere: ANIMATEURS, poids: 1 }, + { critere: REDONDANCE, poids: 5 }, + ]); + expect(journal(m)).toBe(e0 + 1); + }); + + test('« Rétablir l’ordre par défaut » : sans fenêtre, une entrée, le réglage retiré, annoncé', async () => { + const m = await demonstration(); + await cliquer(m, bouton(m, REDONDANCE, 'joindrePalier')); + const e0 = journal(m); + await cliquer(m, editeur(m).querySelector('[data-commande="retablirPriorites"]')); + expect(document.querySelector('dialog[open]')).toBeNull(); + expect(Object.hasOwn(charge(m).reglages, 'priorites')).toBe(false); + expect(journal(m)).toBe(e0 + 1); + expect(liste(m).querySelectorAll(':scope > li[data-palier]')).toHaveLength(7); + expect(annonce(m)).toBe(t('generation.priorites.annonceRetablie')); + }); + + test('en lecture et sur un plan bloqué : le geste refusé contre la liste, avec son remède ; rien d’écrit', async () => { + const m = await demonstration({ ecriture: false }); + const ecritures = m.trace.length; + await cliquer(m, bouton(m, REDONDANCE, 'joindrePalier')); + const lecture = editeur(m).querySelector(':scope > [data-refus="MODE_LECTURE"]'); + expect(lecture).not.toBeNull(); + // Le groupe de l'éditeur est décrit par le refus ; le focus reste sur + // le bouton du geste. + expect(editeur(m).getAttribute('aria-describedby')).toBe(lecture.id); + expect(document.activeElement).toBe(bouton(m, REDONDANCE, 'joindrePalier')); + expect(lecture.querySelector('[data-commande="modifier"]')).not.toBeNull(); + expect(Object.hasOwn(charge(m).reglages, 'priorites')).toBe(false); + expect(m.trace.length).toBe(ecritures); + + await m.seance.passerEnEcriture(); + await m.seance.executer('changerEtat', { etat: 'bloque' }); + flushSync(); + const ecrituresBloque = m.trace.length; + await cliquer(m, bouton(m, CUMULEES, 'monterCritere')); + expect(editeur(m).querySelector(':scope > [data-refus="LECTURE_SEULE"] [data-commande="debloquer"]')).not.toBeNull(); + expect(Object.hasOwn(charge(m).reglages, 'priorites')).toBe(false); + expect(m.trace.length).toBe(ecrituresBloque); + }); +}); + +describe('le mode des objectifs (§ 5.3, A8, A12, D22, D23)', () => { + test('sous la case « Respecter les objectifs des tables » : sa légende, trois boutons radio expliqués, le défaut choisi, l’état du critère écrit à côté', async () => { + const m = await demonstration(); + const caseObjectifs = section(m).querySelector('input[type="checkbox"][data-champ="respecterObjectifs"]'); + const caseAnimateurs = section(m).querySelector('input[type="checkbox"][data-champ="rencontrerAnimateurs"]'); + expect(caseObjectifs.compareDocumentPosition(groupe(m)) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(groupe(m).compareDocumentPosition(caseAnimateurs) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(groupe(m).tagName).toBe('FIELDSET'); + expect(groupe(m).querySelector('legend').textContent.trim()).toBe(t('generation.reglage.modeObjectifs')); + for (const valeur of MODES_OBJECTIFS) { + const r = radio(m, valeur); + expect(groupe(m).querySelector(`label[for="${r.id}"]`).textContent.trim()).toBe(t(`generation.reglage.modeObjectifs.${valeur}`)); + expect(document.getElementById(r.getAttribute('aria-describedby')).textContent.trim()).toBe( + t(`generation.reglage.modeObjectifs.${valeur}.explication`), + ); + expect(r.checked, valeur).toBe(valeur === 'places'); + } + expect(groupe(m).textContent).toContain(t('generation.reglage.modeObjectifs.etat.sansObjet')); + }); + + test('un choix au pointeur, une entrée ; au clavier, la flèche choisit le voisin ; l’état décoché s’écrit sans recalcul', async () => { + const m = await demonstration(); + const e0 = journal(m); + await cliquer(m, radio(m, 'chacun')); + expect(charge(m).reglages.modeObjectifs).toBe('chacun'); + expect(radio(m, 'chacun').checked).toBe(true); + expect(journal(m)).toBe(e0 + 1); + radio(m, 'chacun').focus(); + await presser(m, 'ArrowDown'); + expect(charge(m).reglages.modeObjectifs).toBe('minimum'); + expect(radio(m, 'minimum').checked).toBe(true); + expect(journal(m)).toBe(e0 + 2); + await cliquer(m, section(m).querySelector('input[type="checkbox"][data-champ="respecterObjectifs"]')); + expect(groupe(m).textContent).toContain(t('generation.reglage.modeObjectifs.etat.decoche')); + // Toujours modifiable, case décochée. + await cliquer(m, radio(m, 'places')); + expect(Object.hasOwn(charge(m).reglages, 'modeObjectifs')).toBe(false); + }); + + test('rouge : sous « chacun », pas de « joindre » aux tours hors objectif ; joints, « l’équité d’abord » refusé contre le groupe, le choix revenu', async () => { + const m = await demonstration(); + await cliquer(m, radio(m, 'chacun')); + expect(offerts(m, HORS_OBJECTIF)).not.toContain('joindrePalier'); + await cliquer(m, radio(m, 'places')); + expect(offerts(m, HORS_OBJECTIF)).toContain('joindrePalier'); + await cliquer(m, bouton(m, HORS_OBJECTIF, 'joindrePalier')); + const e0 = journal(m); + await cliquer(m, radio(m, 'minimum')); + const refus = groupe(m).querySelector('[data-refus="OBJECTIFS_PALIER_PARTAGE"]'); + expect(refus).not.toBeNull(); + expect(refus.querySelector(':scope > p').textContent).toBe(t('OBJECTIFS_PALIER_PARTAGE', { mode: 'minimum', palier: 2 })); + // Chaque bouton radio est décrit par le refus, en plus de son + // explication ; le focus reste dans le groupe. + expect(refus.id).not.toBe(''); + for (const valeur of MODES_OBJECTIFS) { + expect(radio(m, valeur).getAttribute('aria-describedby').split(' '), valeur).toContain(refus.id); + } + expect(groupe(m).contains(document.activeElement)).toBe(true); + expect(radio(m, 'places').checked).toBe(true); + expect(radio(m, 'minimum').checked).toBe(false); + expect(Object.hasOwn(charge(m).reglages, 'modeObjectifs')).toBe(false); + expect(journal(m)).toBe(e0); + }); + + test('en lecture : le choix refusé contre le groupe, le mode inchangé, le choix revenu', async () => { + const m = await demonstration({ ecriture: false }); + await cliquer(m, radio(m, 'chacun')); + expect(groupe(m).querySelector('[data-refus="MODE_LECTURE"]')).not.toBeNull(); + expect(radio(m, 'places').checked).toBe(true); + expect(radio(m, 'chacun').checked).toBe(false); + expect(Object.hasOwn(charge(m).reglages, 'modeObjectifs')).toBe(false); + }); + + test('sur un plan bloqué : le choix refusé contre le groupe, avec « Débloquer » ; rien d’écrit, le choix revenu', async () => { + const m = await demonstration(); + await m.seance.executer('changerEtat', { etat: 'bloque' }); + flushSync(); + const ecritures = m.trace.length; + await cliquer(m, radio(m, 'chacun')); + expect(groupe(m).querySelector('[data-refus="LECTURE_SEULE"] [data-commande="debloquer"]')).not.toBeNull(); + expect(radio(m, 'places').checked).toBe(true); + expect(radio(m, 'chacun').checked).toBe(false); + expect(m.trace.length).toBe(ecritures); + expect(Object.hasOwn(charge(m).reglages, 'modeObjectifs')).toBe(false); + }); +}); + +describe('le contraste de l’éditeur et du choix du mode (§ 14.5)', () => { + test('chaque texte, poids et refus compris, à 4,5:1 au moins dans les deux thèmes, mesuré au rendu', async () => { + const m = await demonstration(); + // Un palier partagé, ses poids, et un refus contre le groupe du mode. + await cliquer(m, bouton(m, HORS_OBJECTIF, 'joindrePalier')); + await cliquer(m, radio(m, 'minimum')); + expect(groupe(m).querySelector('[data-refus]')).not.toBeNull(); + expect(editeur(m).querySelectorAll('input[data-champ^="poids."]')).toHaveLength(2); + // Une faute est un texte sous 4,5:1 ou sans fond opaque dessous : la + // liste vide porte le seuil. + for (const theme of ['clair', 'sombre']) { + await sousTheme(theme, async () => { + for (const [nom, racine] of [['éditeur', editeur(m)], ['mode', groupe(m)]]) { + const { fautes, mesures } = releverTextes(racine); + expect(mesures, `${theme} ${nom}`).toBeGreaterThan(3); + expect(fautes, `${theme} ${nom}`).toEqual([]); + } + }); + } }); }); diff --git a/src/interface/generation/Reglages.svelte b/src/interface/generation/Reglages.svelte index 2f876ed..bc5f739 100644 --- a/src/interface/generation/Reglages.svelte +++ b/src/interface/generation/Reglages.svelte @@ -27,7 +27,7 @@ // « Générer ». Son refus, refusGraine, se pose contre elle ; une frappe // appelle saisirGraine, qui le retire. // - // Les quatre contraintes et l'attribution des sièges (§ 5.3) sont des + // Les six contraintes et l'attribution des sièges (§ 5.3) sont des // cases à cocher, chacune sous son nom du spec : un clic, ou la barre // d'espace, exécute modifierReglage {cle, valeur} — une entrée par // bascule. À l'issue du geste, la case montre la valeur de la charge, @@ -35,6 +35,22 @@ // bloqué, support rompu — se pose à côté de la case, avec son remède, et // rien ne s'écrit. // + // Un choix (§ 5.3, A8) se montre sous sa bascule (entree.sous) : un groupe + // data-mode-objectifs, sa légende, un bouton radio par valeur, chacun sous + // son libellé et décrit par son explication (aria-describedby), puis par + // le refus du groupe quand il y en a un. Il reste modifiable quel que soit + // l'état de son critère, que le groupe écrit en texte, « décoché » ou + // « sans objet », tel que la table d'évaluation le porte + // (etatsDesCriteres) : rien ne se recalcule ici. Choisir, au pointeur + // ou aux flèches du clavier, exécute modifierReglage {cle, valeur} — une + // entrée par choix. Un refus — un mode qui compare deux valeurs alors que + // son critère partage un palier (OBJECTIFS_PALIER_PARTAGE), la lecture, un + // plan bloqué — se pose contre le groupe, et le choix revient à la valeur + // de la charge. + // + // L'ordre des priorités (genre 'ordre') suit les contraintes, dans son + // éditeur (Priorites.svelte), qui lit les mêmes états. + // // Les cœurs de calcul sont un réglage du poste, que le contrôleur de // génération tient pour la séance du logiciel : ni le fichier ni le journal // ne les portent, et ils se règlent en lecture comme en écriture. La case @@ -54,6 +70,8 @@ import Champ from '../primitives/Champ.svelte'; import Refus from '../primitives/Refus.svelte'; import { effetDe } from '../raccourcis.js'; + import Priorites from './Priorites.svelte'; + import { etatAffiche, etatsDesCriteres } from './priorites.js'; import { REGLAGES_GENERATION, reglagesDeSection, reglagesDuGenre, valeurDuReglage } from './reglages.js'; let { @@ -111,6 +129,14 @@ const deLaGeneration = $derived(reglagesDeSection(definition, 'generation')); const champs = $derived(reglagesDuGenre(deLaGeneration, 'champ')); const bascules = $derived(reglagesDuGenre(deLaGeneration, 'bascule')); + const choix = $derived(reglagesDuGenre(deLaGeneration, 'choix')); + const ordres = $derived(reglagesDuGenre(deLaGeneration, 'ordre')); + // Les choix qui se montrent sous la bascule de clé cle. + const choixSous = (cle) => choix.filter((entree) => entree.sous === cle); + + // L'état de chaque critère, lu dans la table d'évaluation de la charge ; + // null quand la configuration ne se normalise pas. + const etats = $derived(choix.length + ordres.length === 0 ? null : etatsDesCriteres(charge)); // La lecture d'une saisie du réglage nom : la validation du contrôleur sur // les réglages proposés, ce réglage remplacé par la saisie ; son refus, ou @@ -144,6 +170,28 @@ caseACocher.checked = valeurDuReglage(charge.reglages, entree); } } + + // Le refus du dernier choix, { cle, refus }, posé contre son groupe. + let refusChoix = $state.raw(null); + + // Le choix d'un bouton radio : la séance écrit sa valeur ; à l'issue, + // chaque bouton du groupe montre la valeur de la charge, écrite ou + // inchangée — un choix refusé revient ainsi à l'ancien. + async function choisir(evenement, entree) { + const groupe = evenement.currentTarget.closest('fieldset'); + const { value: valeur } = evenement.currentTarget; + const { cle } = entree; + refusChoix = null; + try { + await seance.executer('modifierReglage', { cle, valeur }); + } catch (erreur) { + if (typeof erreur?.code !== 'string') throw erreur; + refusChoix = { cle, refus: { code: erreur.code, details: erreur.details ?? {} } }; + } finally { + const courante = valeurDuReglage(charge.reglages, entree); + for (const bouton of groupe.querySelectorAll(`input[type="radio"][data-champ="${cle}"]`)) bouton.checked = bouton.value === courante; + } + }
    @@ -190,8 +238,43 @@ (refusBascule = null)} /> {/if}
    + {#each choixSous(cle) as entreeChoix (entreeChoix.cle)} + {@const etat = etatAffiche(etats?.get(entreeChoix.critere))} + {@const courante = valeurDuReglage(charge.reglages, entreeChoix)} + {@const idRefusChoix = `${uid}-${entreeChoix.cle}-refus`} + {@const refuse = refusChoix !== null && refusChoix.cle === entreeChoix.cle} +
    + {t(entreeChoix.etiquette)} + {#each entreeChoix.valeurs as valeur (valeur)} + {@const id = `${uid}-${entreeChoix.cle}-${valeur}`} +
    + choisir(evenement, entreeChoix)} + /> + +

    {t(`${entreeChoix.etiquette}.${valeur}.explication`)}

    +
    + {/each} + {#if etat !== null} +

    {t(`${entreeChoix.etiquette}.etat.${etat}`)}

    + {/if} + {#if refuse} + (refusChoix = null)} /> + {/if} +
    + {/each} {/each} + {#each ordres as entree (entree.cle)} + + {/each}
    {t('generation.coeurs.titre')}
    @@ -319,6 +402,52 @@ margin: 0; } + /* Le choix, sous sa case, sur toute la largeur du groupe : sa légende se + lit comme le libellé d'un champ, chaque bouton suivi de son explication + en encre secondaire. */ + .choix { + display: flex; + flex-basis: 100%; + flex-direction: column; + align-items: flex-start; + gap: var(--espace-2); + margin: 0 0 0 var(--espace-5); + padding: 0; + border: none; + } + + .choix > legend { + padding: 0; + margin-bottom: var(--espace-2); + color: var(--couleur-page-texte); + font-size: var(--taille-texte); + font-weight: normal; + letter-spacing: normal; + text-transform: none; + } + + .option { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 0 var(--espace-1); + } + + .option input { + margin: 0; + } + + .option input:focus-visible { + outline: 2px solid var(--couleur-focus); + outline-offset: 2px; + } + + .explication { + grid-column: 2; + color: var(--couleur-page-texte-secondaire); + font-size: var(--taille-texte-petit); + } + p { margin: 0; } diff --git a/src/interface/generation/SectionGeneration.svelte b/src/interface/generation/SectionGeneration.svelte index e69fc4b..67d2b8f 100644 --- a/src/interface/generation/SectionGeneration.svelte +++ b/src/interface/generation/SectionGeneration.svelte @@ -43,6 +43,11 @@ // section, une seule, sur l'explication de cette colonne, et le quitter la // referme ; le terme expliqué porte aria-describedby vers elle. // + // Les réglages (Reglages.svelte) portent le choix du mode des objectifs, + // sous sa case, et l'éditeur de l'ordre des priorités, après les + // contraintes (§ 5.3, § 5.7) : chacun s'écrit par ses commandes, sans que + // la section s'en mêle. + // // Habillage : la meilleure proposition est une carte à elle, chaque valeur // sous son intitulé ; les réglages, « Générer » — l'action principale de // l'écran —, la question, l'avancement et l'issue vivent dans la carte des diff --git a/src/interface/generation/priorites.js b/src/interface/generation/priorites.js new file mode 100644 index 0000000..47c7a52 --- /dev/null +++ b/src/interface/generation/priorites.js @@ -0,0 +1,256 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le modèle de l'éditeur de l'ordre des priorités (§ 5.3, § 5.7, A11, A12), +// pur : Priorites.svelte le câble. Une liste de priorités range les sept +// critères en paliers, du premier au dernier : [[{critere, poids}, …], …]. +// +// Les faits du registre des critères (contrainte 35) se lisent par +// l'application, que l'interface atteint seule : l'ordre du registre dans la +// table d'évaluation d'un événement neuf, qui porte l'état de chaque critère +// dans cet ordre ; la validité d'une liste sous le mode de l'événement dans +// la commande changerPriorites, qui la juge par le registre. Aucune règle du +// registre ne se récrit ici : un geste s'offre quand la commande admettrait +// sa liste, et l'écart au plafond a priori, les rencontres répétées, ou les +// tours hors objectif sous « chacun » et « minimum », ne partagent ainsi +// jamais un palier par l'éditeur. +// +// Quatre gestes, chacun sur un critère (§ 5.7, A12) : +// monterCritere son palier passe devant celui du dessus, compagnons +// compris ; +// descendreCritere son palier passe derrière celui du dessous ; +// joindrePalier le critère quitte son palier et rejoint celui du +// dessus ; un palier vidé disparaît ; +// separerPalier le critère quitte son palier partagé pour un palier à +// lui, juste dessous. +// Chaque liste écrite range les critères d'un palier dans l'ordre du +// registre, et donne le poids 1 au critère seul à son palier, que l'écran ne +// montre pas : deux listes de même effet s'écrivent pareil (A16). Un critère +// qui rejoint un palier y entre au poids 1. +// +// L'état « décoché » ou « sans objet » d'un critère se lit dans la table +// d'évaluation (evaluer, criteres), qui le tient de la normalisation du +// moteur : l'éditeur ne le recalcule jamais (§ 13.2). Une charge qui porte +// des placements se lit dans la table que partagent les sections +// (evaluation, propositions/modele.js) : l'éditeur ne mesure rien de +// nouveau (§ 13.3). +import { appliquerCommande, chargeNeuve } from '../../application/commandes.js'; +import { evaluer } from '../../application/evaluation.js'; +import { SIEGES_PAR_DEFAUT, TOURS_PAR_DEFAUT } from '../../application/evenements.js'; +import { evaluation, figeePartout } from '../propositions/modele.js'; + +/** @typedef {Array>} ListePriorites */ + +// Un événement neuf, aux réglages d'une charge neuve : le témoin sur lequel +// se lisent l'ordre du registre et l'admission d'une liste. +const TEMOIN = chargeNeuve( + { nom: 'priorites', siegesParDefaut: SIEGES_PAR_DEFAUT, tours: TOURS_PAR_DEFAUT }, + () => 'priorites', +).charge; + +/** Les noms des critères, dans l'ordre du registre, tels que la table + * d'évaluation les porte. */ +export const CRITERES_DU_REGISTRE = Object.freeze(evaluer(TEMOIN).criteres.map(({ nom }) => nom)); + +/** L'ordre par défaut (§ 5.7) : chaque critère seul à son palier, au poids + * 1, dans l'ordre du registre. */ +export const LISTE_PAR_DEFAUT = Object.freeze( + CRITERES_DU_REGISTRE.map((critere) => Object.freeze([Object.freeze({ critere, poids: 1 })])), +); + +/** Les gestes de l'éditeur, dans l'ordre de leurs boutons ; chacun est + * aussi la désignation de son bouton (data-commande). */ +export const GESTES = Object.freeze(['monterCritere', 'descendreCritere', 'joindrePalier', 'separerPalier']); + +// Le rang d'un critère dans le registre ; un nom inconnu lève. +function rangDe(critere) { + const rang = CRITERES_DU_REGISTRE.indexOf(critere); + if (rang < 0) throw new RangeError(`critère inconnu de l'éditeur : ${JSON.stringify(critere)}`); + return rang; +} + +// Une liste rangée : chaque palier dans l'ordre du registre, le poids 1 +// pour un critère seul, les paliers vides retirés. Rend une liste neuve. +function ranger(liste) { + return liste + .filter((palier) => palier.length > 0) + .map((palier) => { + const range = [...palier].sort((a, b) => rangDe(a.critere) - rangDe(b.critere)); + return range.map(({ critere, poids }) => ({ critere, poids: range.length === 1 ? 1 : poids })); + }); +} + +// Une copie de la liste, palier par palier, entrée par entrée. +const copier = (liste) => liste.map((palier) => palier.map(({ critere, poids }) => ({ critere, poids }))); + +// L'index du palier du critère ; un critère que la liste ne porte pas lève. +function palierDe(liste, critere) { + rangDe(critere); + const index = liste.findIndex((palier) => palier.some((entree) => entree.critere === critere)); + if (index < 0) throw new RangeError(`critère absent de la liste : ${JSON.stringify(critere)}`); + return index; +} + +/** + * La place d'un critère : l'index de son palier, à partir de 0, le nombre + * de paliers et ses compagnons de palier, dans l'ordre de la liste. + * + * @param {ListePriorites} liste + * @param {string} critere + * @returns {{palier: number, paliers: number, avec: string[]}} + * @throws {RangeError} un critère inconnu ou absent de la liste + */ +export function placeDe(liste, critere) { + const palier = palierDe(liste, critere); + const avec = liste[palier].map((entree) => entree.critere).filter((nom) => nom !== critere); + return { palier, paliers: liste.length, avec }; +} + +/** + * La liste qu'un geste écrit, rangée, ou null quand le geste ne s'applique + * pas à ce critère : monter ou joindre au premier palier, descendre au + * dernier, séparer un critère seul. Ne juge pas la validité de la liste + * sous un mode : gestesOfferts le fait. Ne modifie pas la liste reçue. + * + * @param {ListePriorites} liste une liste complète + * @param {string} geste l'un de GESTES + * @param {string} critere + * @returns {ListePriorites|null} + * @throws {RangeError} un geste inconnu, un critère inconnu ou absent + */ +export function listeApres(liste, geste, critere) { + if (!GESTES.includes(geste)) throw new RangeError(`geste inconnu de l'éditeur : ${JSON.stringify(geste)}`); + const i = palierDe(liste, critere); + const copie = copier(liste); + const echanger = (a, b) => { + [copie[a], copie[b]] = [copie[b], copie[a]]; + return ranger(copie); + }; + if (geste === 'monterCritere') return i === 0 ? null : echanger(i - 1, i); + if (geste === 'descendreCritere') return i === copie.length - 1 ? null : echanger(i, i + 1); + const sansLui = copie[i].filter((entree) => entree.critere !== critere); + if (geste === 'joindrePalier') { + if (i === 0) return null; + copie[i - 1] = [...copie[i - 1], { critere, poids: 1 }]; + copie[i] = sansLui; + return ranger(copie); + } + if (sansLui.length === 0) return null; + copie.splice(i, 1, sansLui, [{ critere, poids: 1 }]); + return ranger(copie); +} + +/** + * Les gestes offerts à un critère, dans l'ordre de GESTES : ceux qui + * s'appliquent (listeApres) et dont la liste est admise. + * + * @param {ListePriorites} liste + * @param {string} critere + * @param {(liste: ListePriorites) => boolean} admet admissionSous, ou une + * épreuve qui juge par le registre + * @returns {string[]} + */ +export function gestesOfferts(liste, critere, admet) { + return GESTES.filter((geste) => { + const apres = listeApres(liste, geste, critere); + return apres !== null && admet(apres); + }); +} + +/** + * La liste où le critère porte ce poids, les autres inchangés. Le poids + * n'est pas jugé ici : la commande refuse un poids hors de son domaine. + * + * @param {ListePriorites} liste + * @param {string} critere + * @param {number} poids + * @returns {ListePriorites} + */ +export function avecPoids(liste, critere, poids) { + palierDe(liste, critere); + return liste.map((palier) => + palier.map((entree) => ({ critere: entree.critere, poids: entree.critere === critere ? poids : entree.poids })), + ); +} + +/** + * L'admission d'une liste sous les réglages d'un événement : vrai quand la + * commande changerPriorites l'écrirait — ou la trouverait égale à l'ordre + * courant —, faux quand elle la refuse par PRIORITES_INVALIDES ou + * POIDS_HORS_DOMAINE. La commande s'applique au témoin, un événement neuf + * qui porte une copie de ces réglages : elle juge la liste par le registre, + * sous le mode des objectifs de l'événement, sans toucher à rien d'autre. + * Tout autre refus remonte. + * + * @param {Object} reglages charge.reglages + * @returns {(liste: ListePriorites) => boolean} + */ +export function admissionSous(reglages) { + const temoin = { ...TEMOIN, reglages: structuredClone(reglages) }; + return (liste) => { + try { + appliquerCommande('changerPriorites', temoin, { paliers: liste }); + return true; + } catch (erreur) { + if (erreur?.code === 'PRIORITES_INVALIDES' || erreur?.code === 'POIDS_HORS_DOMAINE') return false; + if (erreur?.code === 'SANS_EFFET') return true; + throw erreur; + } + }; +} + +// Les états d'une table, par nom. +const parNom = (criteres) => new Map(criteres.map((etat) => [etat.nom, etat])); + +// Les états des charges sans placement déjà lus, par charge : une charge +// figée partout (figeePartout, la règle du cache de la table partagée) ne +// change plus, et revenir à la section ne la relit pas. +const lus = new WeakMap(); + +// Les états d'une charge sans placement, ou null sur un refus qui porte un +// code. +function etatsSansPlacement(charge) { + try { + return parNom(evaluer({ ...charge, propositions: [], retenu: null }, { plafondsAPriori: null }).criteres); + } catch (erreur) { + if (typeof erreur?.code !== 'string') throw erreur; + return null; + } +} + +/** + * L'état de chaque critère de la charge, par nom, tel que la table + * d'évaluation le porte ({nom, coche, sansObjet, valeurs}). Une charge qui + * porte des placements le lit dans la table partagée des sections, mesurée + * une fois ; une charge sans placement, qui n'en a pas, s'évalue sans + * plafond a priori, aucun placement n'étant à mesurer — une fois pour une + * charge figée partout, celle que publie la séance. null quand la + * configuration ne se normalise pas — un refus qui porte un code — : aucun + * état ne s'écrit alors. Toute autre erreur remonte. + * + * @param {Object} charge + * @returns {Map|null} + */ +export function etatsDesCriteres(charge) { + const { table, refus } = evaluation(charge); + if (table !== null) return parNom(table.criteres); + if (refus !== null) return null; + if (lus.has(charge)) return lus.get(charge); + const etats = etatsSansPlacement(charge); + if (figeePartout(charge)) lus.set(charge, etats); + return etats; +} + +/** + * Ce que l'écran écrit d'un état : 'decoche' pour un critère que ses cases + * ne cochent pas, 'sansObjet' pour un critère coché que l'événement ne lui + * donne rien à mesurer, null pour un critère qui compte ou un état inconnu. + * + * @param {{coche: boolean, sansObjet: boolean}|undefined} etat + * @returns {'decoche'|'sansObjet'|null} + */ +export function etatAffiche(etat) { + if (etat === undefined) return null; + if (!etat.coche) return 'decoche'; + return etat.sansObjet ? 'sansObjet' : null; +} diff --git a/src/interface/generation/priorites.test.js b/src/interface/generation/priorites.test.js new file mode 100644 index 0000000..83957db --- /dev/null +++ b/src/interface/generation/priorites.test.js @@ -0,0 +1,323 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du modèle de l'éditeur de l'ordre des priorités (§ 5.3, § 5.7, +// A11, A12, D8, D23) : l'ordre du registre et l'ordre par défaut, lus par +// l'application et égaux à ceux du registre ; les quatre gestes, chacun +// sur la liste qu'il écrit, rangée ; les gestes offerts, exactement ceux +// dont la liste est valide sous le mode — jamais l'écart ni les rencontres +// répétées dans un palier partagé, ni les tours hors objectif sous +// « chacun » et « minimum » — ; l'admission par la commande, égale à la +// validation du registre ; l'état des critères, lu dans la table +// d'évaluation, jamais recalculé — la garde des sources qui le tient vit +// dans test/sources_interface.test.js. Les noms d'épreuve sont inventés. +import assert from 'node:assert/strict'; +import { describe, test } from '../../../test/lanceur.js'; +import { appliquerCommande } from '../../application/commandes.js'; +import { evaluer } from '../../application/evaluation.js'; +import { CRITERES, MODES_OBJECTIFS, PRIORITES_PAR_DEFAUT, verifierPriorites } from '../../moteur/criteres.js'; +import { rechercher } from '../../moteur/recherche.js'; +import { configurationDepuisCharge, creerCharge, priorites } from '../../stockage/document.js'; +import { VERSION } from '../../version.genere.js'; +import { + CRITERES_DU_REGISTRE, + GESTES, + LISTE_PAR_DEFAUT, + admissionSous, + avecPoids, + etatAffiche, + etatsDesCriteres, + gestesOfferts, + listeApres, + placeDe, +} from './priorites.js'; + +const [ECART, EXCEDENT, CUMULEES, HORS_OBJECTIF, REPETEES, ANIMATEURS, REDONDANCE] = CRITERES; + +// Une liste écrite palier par palier : chaque palier, ses critères et leurs +// poids, [nom, poids] ou nom seul au poids 1. +const liste = (...paliers) => + paliers.map((palier) => palier.map((e) => (Array.isArray(e) ? { critere: e[0], poids: e[1] } : { critere: e, poids: 1 }))); + +// La liste par défaut, copiée. +const parDefaut = () => structuredClone(LISTE_PAR_DEFAUT); + +// L'admission du registre sous un mode, qui sert d'oracle aux épreuves. +const admiseSous = (mode) => (candidate) => verifierPriorites(candidate, mode) === null; + +// Les listes atteintes depuis l'ordre par défaut en au plus profondeur +// gestes admis sous le mode, sans doublon, dans l'ordre de leur découverte. +function atteintes(mode, profondeur) { + const vues = new Map([[JSON.stringify(parDefaut()), parDefaut()]]); + let front = [parDefaut()]; + for (let pas = 0; pas < profondeur; pas += 1) { + const suivant = []; + for (const courante of front) { + for (const critere of CRITERES) { + for (const geste of GESTES) { + const apres = listeApres(courante, geste, critere); + if (apres === null || !admiseSous(mode)(apres)) continue; + const cle = JSON.stringify(apres); + if (vues.has(cle)) continue; + vues.set(cle, apres); + suivant.push(apres); + } + } + } + front = suivant; + } + return [...vues.values()]; +} + +describe('l’ordre du registre et l’ordre par défaut, lus par l’application (§ 5.7, D11)', () => { + test('les critères dans l’ordre du registre, et l’ordre par défaut : chacun seul à son palier, au poids 1, figés', () => { + assert.deepEqual([...CRITERES_DU_REGISTRE], [...CRITERES]); + assert.deepEqual(LISTE_PAR_DEFAUT, structuredClone(PRIORITES_PAR_DEFAUT)); + assert.deepEqual(LISTE_PAR_DEFAUT, priorites({ reglages: {} })); + assert.ok(Object.isFrozen(CRITERES_DU_REGISTRE) && Object.isFrozen(LISTE_PAR_DEFAUT)); + assert.ok(LISTE_PAR_DEFAUT.every((palier) => Object.isFrozen(palier) && palier.every(Object.isFrozen))); + }); + + test('placeDe : l’index du palier, le nombre de paliers et les compagnons du critère', () => { + const l = liste([ECART], [EXCEDENT, [CUMULEES, 3]], [HORS_OBJECTIF], [REPETEES], [ANIMATEURS, REDONDANCE]); + assert.deepEqual(placeDe(l, CUMULEES), { palier: 1, paliers: 5, avec: [EXCEDENT] }); + assert.deepEqual(placeDe(l, REPETEES), { palier: 3, paliers: 5, avec: [] }); + assert.throws(() => placeDe(l, 'constructor'), RangeError); + }); +}); + +describe('les quatre gestes : la liste que chacun écrit (§ 5.7, A12)', () => { + test('monter et descendre échangent le palier du critère avec son voisin, compagnons compris ; aux bords, aucun geste', () => { + const l = liste([ECART], [EXCEDENT, [CUMULEES, 3]], [HORS_OBJECTIF], [REPETEES], [ANIMATEURS], [REDONDANCE]); + assert.deepEqual( + listeApres(l, 'monterCritere', CUMULEES), + liste([EXCEDENT, [CUMULEES, 3]], [ECART], [HORS_OBJECTIF], [REPETEES], [ANIMATEURS], [REDONDANCE]), + ); + assert.deepEqual( + listeApres(l, 'descendreCritere', HORS_OBJECTIF), + liste([ECART], [EXCEDENT, [CUMULEES, 3]], [REPETEES], [HORS_OBJECTIF], [ANIMATEURS], [REDONDANCE]), + ); + assert.equal(listeApres(l, 'monterCritere', ECART), null); + assert.equal(listeApres(l, 'descendreCritere', REDONDANCE), null); + }); + + test('joindre : le critère rejoint le palier du dessus, rangé dans l’ordre du registre, au poids 1 ; un palier vidé disparaît', () => { + const l = parDefaut(); + // La redondance rejoint les animateurs : six paliers, le dernier rangé. + assert.deepEqual( + listeApres(l, 'joindrePalier', REDONDANCE), + liste([ECART], [EXCEDENT], [CUMULEES], [HORS_OBJECTIF], [REPETEES], [ANIMATEURS, REDONDANCE]), + ); + // Un critère d'un palier partagé le quitte seul ; son ancien compagnon, + // seul désormais, revient au poids 1. + const partage = liste([ECART], [REPETEES], [EXCEDENT], [[CUMULEES, 4], [HORS_OBJECTIF, 7]], [ANIMATEURS], [REDONDANCE]); + assert.deepEqual( + listeApres(partage, 'joindrePalier', HORS_OBJECTIF), + liste([ECART], [REPETEES], [EXCEDENT, HORS_OBJECTIF], [CUMULEES], [ANIMATEURS], [REDONDANCE]), + ); + // Le palier rejoint se range : le critère joint y prend son rang du + // registre, devant un critère qui le suit. + assert.deepEqual( + listeApres(liste([ECART], [REPETEES], [REDONDANCE], [EXCEDENT], [CUMULEES], [HORS_OBJECTIF], [ANIMATEURS]), 'joindrePalier', EXCEDENT), + liste([ECART], [REPETEES], [EXCEDENT, REDONDANCE], [CUMULEES], [HORS_OBJECTIF], [ANIMATEURS]), + ); + assert.equal(listeApres(l, 'joindrePalier', ECART), null); + }); + + test('séparer : le critère quitte son palier partagé pour un palier à lui, juste dessous ; seul, aucun geste', () => { + const partage = liste([ECART], [[EXCEDENT, 2], [CUMULEES, 5], [HORS_OBJECTIF, 9]], [REPETEES], [ANIMATEURS], [REDONDANCE]); + assert.deepEqual( + listeApres(partage, 'separerPalier', CUMULEES), + liste([ECART], [[EXCEDENT, 2], [HORS_OBJECTIF, 9]], [CUMULEES], [REPETEES], [ANIMATEURS], [REDONDANCE]), + ); + const deux = liste([ECART], [[EXCEDENT, 2], [CUMULEES, 5]], [HORS_OBJECTIF], [REPETEES], [ANIMATEURS], [REDONDANCE]); + assert.deepEqual(listeApres(deux, 'separerPalier', CUMULEES), parDefaut()); + // Séparé, le premier passe sous son ancien compagnon. + assert.deepEqual( + listeApres(deux, 'separerPalier', EXCEDENT), + liste([ECART], [CUMULEES], [EXCEDENT], [HORS_OBJECTIF], [REPETEES], [ANIMATEURS], [REDONDANCE]), + ); + assert.equal(listeApres(parDefaut(), 'separerPalier', EXCEDENT), null); + }); + + test('chaque liste écrite range chaque palier dans l’ordre du registre, poids 1 pour un critère seul ; la liste reçue ne change pas', () => { + const partage = liste([ECART], [[REDONDANCE, 6], [EXCEDENT, 2]], [CUMULEES], [HORS_OBJECTIF], [REPETEES], [ANIMATEURS]); + const avant = structuredClone(partage); + for (const critere of CRITERES) { + for (const geste of GESTES) { + const apres = listeApres(partage, geste, critere); + if (apres === null) continue; + for (const palier of apres) { + const rangs = palier.map(({ critere: nom }) => CRITERES.indexOf(nom)); + assert.deepEqual(rangs, [...rangs].sort((a, b) => a - b), `${geste} ${critere}`); + if (palier.length === 1) assert.equal(palier[0].poids, 1, `${geste} ${critere}`); + } + assert.deepEqual(apres.flat().map(({ critere: nom }) => nom).sort(), [...CRITERES].sort()); + } + } + assert.deepEqual(partage, avant); + }); + + test('un geste inconnu ou un critère inconnu lèvent', () => { + assert.throws(() => listeApres(parDefaut(), 'glisserCritere', EXCEDENT), RangeError); + assert.throws(() => listeApres(parDefaut(), 'monterCritere', '__proto__'), RangeError); + }); + + test('avecPoids : le poids du seul critère nommé change, la liste reçue non', () => { + const l = liste([ECART], [EXCEDENT, [CUMULEES, 3]], [HORS_OBJECTIF], [REPETEES], [ANIMATEURS], [REDONDANCE]); + const avant = structuredClone(l); + assert.deepEqual(avecPoids(l, CUMULEES, 40), liste([ECART], [EXCEDENT, [CUMULEES, 40]], [HORS_OBJECTIF], [REPETEES], [ANIMATEURS], [REDONDANCE])); + assert.deepEqual(l, avant); + }); +}); + +describe('les gestes offerts (§ 5.7, A12, D8, D23)', () => { + test('rouge : « joindre » n’est jamais offert à l’écart, ni aux rencontres répétées, ni au critère qui les suit', () => { + for (const mode of MODES_OBJECTIFS) { + const l = parDefaut(); + assert.ok(!gestesOfferts(l, ECART, admiseSous(mode)).includes('joindrePalier'), mode); + // L'excédent suit l'écart : le joindre partagerait le palier de l'écart. + assert.ok(!gestesOfferts(l, EXCEDENT, admiseSous(mode)).includes('joindrePalier'), mode); + assert.ok(!gestesOfferts(l, REPETEES, admiseSous(mode)).includes('joindrePalier'), mode); + assert.ok(!gestesOfferts(l, ANIMATEURS, admiseSous(mode)).includes('joindrePalier'), mode); + } + }); + + test('rouge : « joindre » n’est pas offert aux tours hors objectif sous « chacun » et « minimum », ni au critère qui les suit ; sous « places », il l’est', () => { + const l = liste([ECART], [EXCEDENT], [CUMULEES], [HORS_OBJECTIF], [ANIMATEURS], [REPETEES], [REDONDANCE]); + for (const mode of ['chacun', 'minimum']) { + assert.ok(!gestesOfferts(l, HORS_OBJECTIF, admiseSous(mode)).includes('joindrePalier'), mode); + assert.ok(!gestesOfferts(l, ANIMATEURS, admiseSous(mode)).includes('joindrePalier'), mode); + } + assert.ok(gestesOfferts(l, HORS_OBJECTIF, admiseSous('places')).includes('joindrePalier')); + assert.ok(gestesOfferts(l, ANIMATEURS, admiseSous('places')).includes('joindrePalier')); + }); + + test('sous chaque mode, sur chaque liste atteinte en trois gestes, un geste est offert exactement quand il s’applique et que sa liste est valide', () => { + for (const mode of MODES_OBJECTIFS) { + const admet = admiseSous(mode); + const listes = atteintes(mode, 3); + assert.ok(listes.length > 100, `${mode} : ${listes.length} listes`); + for (const courante of listes) { + assert.equal(verifierPriorites(courante, mode), null); + for (const critere of CRITERES) { + const offerts = gestesOfferts(courante, critere, admet); + const attendus = GESTES.filter((geste) => { + const apres = listeApres(courante, geste, critere); + return apres !== null && verifierPriorites(apres, mode) === null; + }); + assert.deepEqual(offerts, attendus, `${mode} ${critere} ${JSON.stringify(courante)}`); + } + } + } + }); + + test('sous chaque mode, sur chaque liste atteinte en deux gestes, un geste offert se défait par son inverse, offert, qui rend la liste de départ', () => { + // L'inverse de chaque geste : monter et descendre l'un de l'autre ; + // séparer par joindre ; joindre par séparer, pour un critère seul à son + // palier — joint depuis un palier partagé, il laisse ses compagnons + // dans un palier à eux, que séparer ne rejoint pas. + const INVERSES = new Map([ + ['monterCritere', 'descendreCritere'], + ['descendreCritere', 'monterCritere'], + ['joindrePalier', 'separerPalier'], + ['separerPalier', 'joindrePalier'], + ]); + let defaits = 0; + for (const mode of MODES_OBJECTIFS) { + const admet = admiseSous(mode); + for (const depart of atteintes(mode, 2)) { + for (const critere of CRITERES) { + const seul = placeDe(depart, critere).avec.length === 0; + for (const geste of gestesOfferts(depart, critere, admet)) { + if (geste === 'joindrePalier' && !seul) continue; + const apres = listeApres(depart, geste, critere); + const inverse = INVERSES.get(geste); + const message = `${mode} ${geste} ${critere} ${JSON.stringify(depart)}`; + assert.ok(gestesOfferts(apres, critere, admet).includes(inverse), message); + assert.deepEqual(listeApres(apres, inverse, critere), depart, message); + defaits += 1; + } + } + } + } + assert.ok(defaits > 1000, `${defaits} gestes défaits`); + // Un poids réglé se garde quand monter puis descendre déplacent le palier. + const l = liste([ECART], [EXCEDENT, [CUMULEES, 3]], [HORS_OBJECTIF], [REPETEES], [ANIMATEURS], [REDONDANCE]); + assert.deepEqual(listeApres(listeApres(l, 'monterCritere', HORS_OBJECTIF), 'descendreCritere', HORS_OBJECTIF), l); + }); +}); + +describe('l’admission par la commande de l’application (§ 5.9, contrainte 35)', () => { + // Les réglages d'un événement neuf, sous le mode et l'ordre donnés. + const reglagesSous = (mode, ordre) => { + const { reglages } = creerCharge({ id: 'evt-priorites', nom: 'Soirée des Merles', siegesParDefaut: 6, tours: 3 }); + if (mode !== 'places') reglages.modeObjectifs = mode; + if (ordre !== null) reglages.priorites = ordre; + return reglages; + }; + + test('sous chaque mode, la commande admet exactement les listes que le registre valide, sur chaque geste de chaque liste atteinte en un geste', () => { + for (const mode of MODES_OBJECTIFS) { + for (const courante of atteintes(mode, 1)) { + const admet = admissionSous(reglagesSous(mode, courante)); + for (const critere of CRITERES) { + for (const geste of GESTES) { + const apres = listeApres(courante, geste, critere); + if (apres === null) continue; + assert.equal(admet(apres), verifierPriorites(apres, mode) === null, `${mode} ${geste} ${critere}`); + } + } + } + } + }); + + test('un poids hors domaine n’est pas admis ; la liste courante l’est ; les réglages reçus ne changent pas', () => { + const reglages = reglagesSous('places', null); + const avant = structuredClone(reglages); + const admet = admissionSous(reglages); + const deux = listeApres(parDefaut(), 'joindrePalier', REDONDANCE); + assert.equal(admet(avecPoids(deux, REDONDANCE, 0)), false); + assert.equal(admet(avecPoids(deux, REDONDANCE, 101)), false); + assert.equal(admet(avecPoids(deux, REDONDANCE, 100)), true); + assert.equal(admet(parDefaut()), true); + assert.deepEqual(reglages, avant); + }); +}); + +describe('l’état des critères, lu dans la table d’évaluation (§ 5.3, A8, D10)', () => { + // Un événement de trois personnes inventées et une table de six places. + function chargeTrois() { + let charge = creerCharge({ id: 'evt-etat', nom: 'Soirée des Merles', siegesParDefaut: 6, tours: 2 }); + for (const [nom, prenom] of [['Ombrelle', 'Iris'], ['Pervenche', 'Théo'], ['Lacasse', 'Ondine']]) { + charge = appliquerCommande('ajouterParticipant', charge, { champs: { nom, prenom } }).charge; + } + return appliquerCommande('creerTable', charge, { forme: 'ronde' }).charge; + } + + test('etatsDesCriteres rend, par nom, l’état que la table d’évaluation porte, propositions comprises ou non', () => { + const charge = chargeTrois(); + const etats = etatsDesCriteres(charge); + assert.deepEqual([...etats.keys()], [...CRITERES]); + assert.deepEqual([...etats.values()], evaluer(charge).criteres); + assert.equal(etatAffiche(etats.get(HORS_OBJECTIF)), 'sansObjet'); + assert.equal(etatAffiche(etats.get(ANIMATEURS)), 'decoche'); + assert.equal(etatAffiche(etats.get(EXCEDENT)), null); + const decochee = appliquerCommande('modifierReglage', charge, { cle: 'respecterObjectifs', valeur: false }).charge; + assert.equal(etatAffiche(etatsDesCriteres(decochee).get(HORS_OBJECTIF)), 'decoche'); + assert.equal(etatAffiche(undefined), null); + // Avec des propositions : l'état de la table qui les mesure. + const configuration = configurationDepuisCharge(charge); + const propositions = rechercher(configuration, { graine: 3, nombre: 2, arret: 50, historique: 10 }); + const generee = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: VERSION.affichee }).charge; + assert.equal(generee.propositions.length, 2); + assert.deepEqual([...etatsDesCriteres(generee).values()], evaluer(generee).criteres); + }); + + test('une configuration que la mesure refuse, par un code, ne donne aucun état', () => { + const charge = chargeTrois(); + charge.reglages.modeObjectifs = 'minimum'; + charge.reglages.priorites = liste([ECART], [EXCEDENT], [CUMULEES, HORS_OBJECTIF], [REPETEES], [ANIMATEURS], [REDONDANCE]); + assert.equal(etatsDesCriteres(charge), null); + }); +}); diff --git a/src/interface/generation/reglages.js b/src/interface/generation/reglages.js index 0d7b4f8..446abc3 100644 --- a/src/interface/generation/reglages.js +++ b/src/interface/generation/reglages.js @@ -7,7 +7,8 @@ // de 'generation' —, et le tableau des réglages de la documentation // s'engendre de toutes : un réglage ajouté ici paraît des deux côtés. // -// Une entrée est { nom, cle, etiquette, unite, defaut, genre, section } : +// Une entrée est { nom, cle, etiquette, unite, defaut, genre, section }, et +// une entrée du genre 'choix' porte en plus { valeurs, sous, critere } : // nom son nom dans charge.reglages, ou dans charge.reglages.generation // pour un champ de la génération ; // cle son chemin dans charge.reglages, que la commande qui l'écrit @@ -15,13 +16,24 @@ // generation. pour un champ de la génération, sinon ; // etiquette la clé du libellé, qui porte l'unité pour un champ ; // unite ce qu'un champ compte, 'propositions' ou 'mouvements' ; null -// pour une bascule et pour la cible, une somme sans unité ; +// pour une bascule, un choix, l'ordre, et pour la cible, une +// somme sans unité ; // defaut la valeur d'une charge neuve, ou celle d'un réglage facultatif // que la charge ne porte pas ; -// genre 'champ', une saisie entière, ou 'bascule', une case à cocher ; +// genre 'champ', une saisie entière ; 'bascule', une case à cocher ; +// 'choix', une valeur parmi valeurs, en boutons radio ; 'ordre', +// l'ordre des priorités, une liste de paliers que son éditeur +// règle (Priorites.svelte) ; // section la section de l'écran qui le montre : 'generation', écrit par -// modifierReglage, ou 'propositions', la cible de la somme de -// redondance, écrite par changerCibleRedondance. +// modifierReglage — l'ordre par changerPriorites —, ou +// 'propositions', la cible de la somme de redondance, écrite par +// changerCibleRedondance ; +// valeurs les valeurs d'un choix, dans l'ordre de ses boutons ; le +// libellé de chacune est ., son explication +// ..explication ; +// sous la clé de la bascule sous laquelle le choix se montre ; +// critere le critère dont le choix règle la lecture : son état, +// « décoché » ou « sans objet », s'écrit à côté du choix. // // La graine, saisie de la commande, et les cœurs, réglage du poste, ne sont // pas au fichier : Reglages.svelte les montre à part. @@ -29,19 +41,32 @@ // Les défauts se lisent sur la charge d'un événement neuf, telle que la // commande de création la pose (chargeNeuve) : creerCharge y copie // GENERATION_PAR_DEFAUT et y écrit les bascules, et l'interface n'importe -// pas le stockage. La charge lue ici n'est écrite nulle part. La cible, -// facultative, n'y est pas : son défaut est CIBLE_REDONDANCE_PAR_DEFAUT. +// pas le stockage. La charge lue ici n'est écrite nulle part. Les réglages +// facultatifs n'y sont pas : la cible a pour défaut +// CIBLE_REDONDANCE_PAR_DEFAUT, l'ordre des priorités LISTE_PAR_DEFAUT, lue +// dans le registre par l'application (priorites.js). Le mode prend ses +// valeurs et son défaut, le premier, dans MODES_OBJECTIFS, et son critère +// dans CASES, celui que sa case fait compter : le registre, que +// l'application réexporte (contrainte 35). Les deux cases portent ici le +// défaut que le stockage leur lit (§ 8.9), et les épreuves de +// reglages.test.js les comparent à ses lecteurs. import { chargeNeuve } from '../../application/commandes.js'; -import { CIBLE_REDONDANCE_PAR_DEFAUT } from '../../application/evaluation.js'; +import { CASES, CIBLE_REDONDANCE_PAR_DEFAUT, MODES_OBJECTIFS } from '../../application/evaluation.js'; import { SIEGES_PAR_DEFAUT, TOURS_PAR_DEFAUT } from '../../application/evenements.js'; +import { LISTE_PAR_DEFAUT } from './priorites.js'; -/** @typedef {'champ'|'bascule'} GenreReglage */ +/** @typedef {'champ'|'bascule'|'choix'|'ordre'} GenreReglage */ /** @typedef {'generation'|'propositions'} SectionReglage */ /** * @typedef {Readonly<{nom: string, cle: string, etiquette: string, unite: 'propositions'|'mouvements'|null, - * defaut: number|boolean, genre: GenreReglage, section: SectionReglage}>} EntreeReglage + * defaut: number|boolean|string|ReadonlyArray>, + * genre: GenreReglage, section: SectionReglage, + * valeurs?: ReadonlyArray, sous?: string, critere?: string}>} EntreeReglage */ +// Les genres que valeurDuReglage lit. +const GENRES = Object.freeze(['champ', 'bascule', 'choix', 'ordre']); + const { reglages: DEFAUTS } = chargeNeuve( { nom: 'reglages', siegesParDefaut: SIEGES_PAR_DEFAUT, tours: TOURS_PAR_DEFAUT }, () => 'reglages', @@ -58,17 +83,49 @@ const champ = (nom, unite) => section: 'generation', }); -const bascule = (nom) => +// Une bascule ; defaut, pour une case facultative que la charge neuve ne +// porte pas. +const bascule = (nom, defaut = DEFAUTS[nom]) => Object.freeze({ nom, cle: nom, etiquette: `generation.contrainte.${nom}`, unite: null, - defaut: DEFAUTS[nom], + defaut, genre: 'bascule', section: 'generation', }); +// Le mode des objectifs (§ 5.3, A8, D20) : ce que le critère des tours hors +// objectif cherche d'abord. Le défaut, en tête des modes du registre, est le +// plus de places servies, que le fichier n'écrit pas. Le choix se montre +// sous la case des objectifs, et son critère est celui que cette case fait +// compter. +const SOUS_LE_MODE = 'respecterObjectifs'; +const MODE_OBJECTIFS = Object.freeze({ + nom: 'modeObjectifs', + cle: 'modeObjectifs', + etiquette: 'generation.reglage.modeObjectifs', + unite: null, + defaut: MODES_OBJECTIFS[0], + genre: 'choix', + section: 'generation', + valeurs: MODES_OBJECTIFS, + sous: SOUS_LE_MODE, + critere: Object.keys(CASES).find((nom) => CASES[nom].includes(SOUS_LE_MODE)), +}); + +// L'ordre des priorités (§ 5.7, A11) : absent du fichier à l'ordre par défaut. +const PRIORITES = Object.freeze({ + nom: 'priorites', + cle: 'priorites', + etiquette: 'generation.reglage.priorites', + unite: null, + defaut: LISTE_PAR_DEFAUT, + genre: 'ordre', + section: 'generation', +}); + const CIBLE = Object.freeze({ nom: 'cibleRedondance', cle: 'cibleRedondance', @@ -81,9 +138,10 @@ const CIBLE = Object.freeze({ /** * Les réglages de la charge, dans l'ordre des écrans : les trois champs de - * la recherche (§ 5.7, § 5.10), puis les quatre contraintes et l'attribution - * des sièges, dans l'ordre du § 5.3 ; enfin la cible de la somme de - * redondance (§ 8.9), de la section des propositions. + * la recherche (§ 5.7, § 5.10), puis les six contraintes et l'attribution + * des sièges, dans l'ordre du § 5.3 ; le mode des objectifs, que l'écran + * montre sous sa case ; l'ordre des priorités ; enfin la cible de la somme + * de redondance (§ 8.9), de la section des propositions. * * @type {ReadonlyArray} */ @@ -95,7 +153,11 @@ export const REGLAGES_GENERATION = Object.freeze([ bascule('nouveauxVoisins'), bascule('nouvelleTable'), bascule('varierAppartenances'), + bascule('respecterObjectifs', true), + bascule('rencontrerAnimateurs', false), bascule('attribuerSieges'), + MODE_OBJECTIFS, + PRIORITES, CIBLE, ]); @@ -124,15 +186,17 @@ export function reglagesDeSection(definition, section) { /** * La valeur qu'une charge porte pour une entrée, lue au chemin de sa clé : * reglages.generation[nom] pour un champ de la génération, reglages[nom] - * sinon. Un réglage facultatif absent vaut le défaut de l'entrée. + * sinon — une case, le mode, l'ordre des priorités. Un réglage facultatif + * absent vaut le défaut de l'entrée. Un ordre se rend tel que la charge le + * porte : le stockage le valide à l'écriture comme à la lecture (A16). * * @param {Object} reglages charge.reglages * @param {EntreeReglage} entree - * @returns {number|boolean} + * @returns {EntreeReglage['defaut']} * @throws {RangeError} pour un genre inconnu */ export function valeurDuReglage(reglages, entree) { - if (entree.genre !== 'champ' && entree.genre !== 'bascule') { + if (!GENRES.includes(entree.genre)) { throw new RangeError(`genre de réglage inconnu : ${JSON.stringify(entree.genre)}`); } const valeur = entree.cle.split('.').reduce((objet, nom) => objet?.[nom], reglages); diff --git a/src/interface/generation/reglages.test.js b/src/interface/generation/reglages.test.js index 6b7b021..dc50d2c 100644 --- a/src/interface/generation/reglages.test.js +++ b/src/interface/generation/reglages.test.js @@ -4,15 +4,26 @@ // Épreuves de la définition des réglages de la charge (§ 5.3, § 5.7, // § 5.10, § 8.9, § 19.8) : sa forme, figée ; l'ordre des écrans ; les // défauts, ceux d'une charge neuve, GENERATION_PAR_DEFAUT et -// CIBLE_REDONDANCE_PAR_DEFAUT compris ; chaque réglage du schéma du fichier, -// facultatif compris, et lui seul ; les clés que les commandes acceptent et -// les libellés qui existent ; la lecture d'une valeur dans une charge. +// CIBLE_REDONDANCE_PAR_DEFAUT compris, et ceux que les lecteurs du stockage +// donnent aux réglages facultatifs — les deux cases, le mode des objectifs, +// l'ordre des priorités ; chaque réglage du schéma du fichier, facultatif +// compris, et lui seul ; les clés que les commandes acceptent et les +// libellés qui existent ; la lecture d'une valeur dans une charge. import assert from 'node:assert/strict'; import { describe, test } from '../../../test/lanceur.js'; import { TABLES_APPLICATION } from '../../application/libelles.js'; import { appliquerCommande } from '../../application/commandes.js'; import { creerTraduction } from '../../application/traduction.js'; -import { CIBLE_REDONDANCE_PAR_DEFAUT, GENERATION_PAR_DEFAUT, SCHEMA, creerCharge } from '../../stockage/document.js'; +import { MODES_OBJECTIFS } from '../../moteur/criteres.js'; +import { + CIBLE_REDONDANCE_PAR_DEFAUT, + GENERATION_PAR_DEFAUT, + SCHEMA, + creerCharge, + critereCoche, + modeObjectifs, + priorites, +} from '../../stockage/document.js'; import { TABLES_INTERFACE } from '../libelles.js'; import { REGLAGES_GENERATION, reglagesDeSection, reglagesDuGenre, valeurDuReglage } from './reglages.js'; @@ -44,30 +55,48 @@ function cheminsDuSchema() { // n'avoir aucune entrée : l'entrée qui s'ajoute l'en retire. const SANS_ENTREE = new Map([ ['regleObjectifs', 'la règle par défaut des objectifs s’écrit depuis les tables, non depuis la génération'], - ['respecterObjectifs', 'case des objectifs, sans entrée à l’écran de la génération'], - ['modeObjectifs', 'mode des objectifs, sans entrée à l’écran de la génération'], - ['rencontrerAnimateurs', 'case des animateurs, sans entrée à l’écran de la génération'], - ['priorites', 'ordre des priorités, écrit par son éditeur de la section Génération, non par une entrée'], ]); -// La commande qui écrit un réglage de chaque section, et ses arguments. +// La commande qui écrit un réglage de chaque section, et ses arguments : +// l'ordre des priorités par changerPriorites, les autres réglages de la +// génération par modifierReglage. const ECRITURES = new Map([ - ['generation', (entree, valeur) => ['modifierReglage', { cle: entree.cle, valeur }]], + [ + 'generation', + (entree, valeur) => + entree.genre === 'ordre' ? ['changerPriorites', { paliers: valeur }] : ['modifierReglage', { cle: entree.cle, valeur }], + ], ['propositions', (entree, valeur) => ['changerCibleRedondance', { cible: String(valeur) }]], ]); +// Une valeur autre que le défaut de l'entrée : la case contraire, un mode +// voisin, un entier de plus, l'ordre par défaut aux deux derniers paliers +// joints. +function autreValeur({ genre, defaut, valeurs }) { + if (genre === 'bascule') return !defaut; + if (genre === 'choix') return valeurs.find((valeur) => valeur !== defaut); + if (genre === 'ordre') return [...defaut.slice(0, -2), [...defaut.at(-2), ...defaut.at(-1)]]; + return defaut + 1; +} + +// Les clés de chaque entrée, et celles qu'une entrée du genre choix porte en +// plus : ses valeurs, la bascule sous laquelle elle se montre, le critère +// dont elle règle la lecture. +const CLES_D_ENTREE = ['nom', 'cle', 'etiquette', 'unite', 'defaut', 'genre', 'section']; +const CLES_DU_CHOIX = ['valeurs', 'sous', 'critere']; + describe('REGLAGES_GENERATION : la définition que lisent les écrans et la documentation (§ 19.8)', () => { - test('chaque entrée porte nom, cle, etiquette, unite, defaut, genre et section ; la définition comme chaque entrée sont figées', () => { + test('chaque entrée porte nom, cle, etiquette, unite, defaut, genre et section, un choix ses valeurs, sa bascule et son critère ; la définition comme chaque entrée sont figées', () => { assert.ok(REGLAGES_GENERATION.length > 0, 'définition vide'); assert.ok(Object.isFrozen(REGLAGES_GENERATION)); for (const entree of REGLAGES_GENERATION) { - assert.deepEqual(Object.keys(entree), ['nom', 'cle', 'etiquette', 'unite', 'defaut', 'genre', 'section']); + assert.deepEqual(Object.keys(entree), entree.genre === 'choix' ? [...CLES_D_ENTREE, ...CLES_DU_CHOIX] : CLES_D_ENTREE); assert.ok(Object.isFrozen(entree), entree.nom); } assert.throws(() => REGLAGES_GENERATION.push({}), TypeError); }); - test('l’ordre des écrans : les trois champs de la recherche, les bascules du § 5.3, puis la cible de la section des propositions', () => { + test('l’ordre des écrans : les trois champs de la recherche, les bascules du § 5.3, le mode des objectifs, l’ordre des priorités, puis la cible de la section des propositions', () => { assert.deepEqual( REGLAGES_GENERATION.map(({ nom, genre, section }) => [section, genre, nom]), [ @@ -78,7 +107,11 @@ describe('REGLAGES_GENERATION : la définition que lisent les écrans et la docu ['generation', 'bascule', 'nouveauxVoisins'], ['generation', 'bascule', 'nouvelleTable'], ['generation', 'bascule', 'varierAppartenances'], + ['generation', 'bascule', 'respecterObjectifs'], + ['generation', 'bascule', 'rencontrerAnimateurs'], ['generation', 'bascule', 'attribuerSieges'], + ['generation', 'choix', 'modeObjectifs'], + ['generation', 'ordre', 'priorites'], ['propositions', 'champ', 'cibleRedondance'], ], ); @@ -94,13 +127,55 @@ describe('REGLAGES_GENERATION : la définition que lisent les écrans et la docu } assert.deepEqual(champs.map(({ unite }) => unite), ['propositions', 'mouvements', 'mouvements']); const bascules = reglagesDuGenre(generation, 'bascule'); - assert.equal(bascules.length, 5); + assert.equal(bascules.length, 7); for (const { nom, cle, etiquette, unite } of bascules) { assert.equal(cle, nom); assert.equal(etiquette, `generation.contrainte.${nom}`); assert.equal(unite, null); } - assert.equal(champs.length + bascules.length, generation.length); + const choix = reglagesDuGenre(generation, 'choix'); + const ordres = reglagesDuGenre(generation, 'ordre'); + assert.equal(champs.length + bascules.length + choix.length + ordres.length, generation.length); + }); + + test('le mode des objectifs : un choix à la racine des réglages, ses trois valeurs celles du registre, le plus de places servies par défaut, sous la case des objectifs, réglant les tours hors objectif', () => { + const choix = reglagesDuGenre(REGLAGES_GENERATION, 'choix'); + assert.deepEqual(choix, [ + { + nom: 'modeObjectifs', + cle: 'modeObjectifs', + etiquette: 'generation.reglage.modeObjectifs', + unite: null, + defaut: 'places', + genre: 'choix', + section: 'generation', + valeurs: [...MODES_OBJECTIFS], + sous: 'respecterObjectifs', + critere: 'toursHorsObjectif', + }, + ]); + assert.ok(Object.isFrozen(choix[0].valeurs)); + assert.equal(choix[0].defaut, modeObjectifs({ reglages: {} })); + assert.ok(reglagesDuGenre(REGLAGES_GENERATION, 'bascule').some(({ cle }) => cle === choix[0].sous)); + for (const valeur of choix[0].valeurs) { + assert.notEqual(t(`${choix[0].etiquette}.${valeur}`), `${choix[0].etiquette}.${valeur}`, valeur); + assert.notEqual(t(`${choix[0].etiquette}.${valeur}.explication`), `${choix[0].etiquette}.${valeur}.explication`, valeur); + } + }); + + test('l’ordre des priorités : une entrée à la racine des réglages, sans unité, l’ordre par défaut que lit le stockage', () => { + const [ordre] = reglagesDuGenre(REGLAGES_GENERATION, 'ordre'); + assert.deepEqual({ ...ordre, defaut: null }, { + nom: 'priorites', + cle: 'priorites', + etiquette: 'generation.reglage.priorites', + unite: null, + defaut: null, + genre: 'ordre', + section: 'generation', + }); + assert.deepEqual(ordre.defaut, priorites({ reglages: {} })); + assert.ok(Object.isFrozen(ordre.defaut)); }); test('la cible de la somme de redondance : un champ de la section des propositions, à la racine des réglages, sous le libellé de son champ, sans unité', () => { @@ -130,8 +205,15 @@ describe('REGLAGES_GENERATION : la définition que lisent les écrans et la docu ); assert.deepEqual( reglagesDuGenre(generation, 'bascule').map(({ defaut }) => defaut), - [true, true, true, true, false], + [true, true, true, true, true, false, false], ); + // Les cases facultatives, absentes d'une charge neuve : leur défaut est + // celui que le stockage leur lit. + for (const cle of ['respecterObjectifs', 'rencontrerAnimateurs']) { + assert.equal(Object.hasOwn(reglages, cle), false, cle); + assert.equal(REGLAGES_GENERATION.find((entree) => entree.cle === cle).defaut, critereCoche({ reglages: {} }, cle), cle); + } + assert.equal(Object.hasOwn(reglages, 'modeObjectifs') || Object.hasOwn(reglages, 'priorites'), false); }); test('chaque réglage du schéma du fichier, facultatif compris, a son entrée, et chaque entrée en désigne un : aucun n’échappe aux écrans ni au tableau, hors des réglages nommés sans entrée', () => { @@ -148,10 +230,10 @@ describe('REGLAGES_GENERATION : la définition que lisent les écrans et la docu test('chaque entrée s’écrit par la commande de sa section, et chaque libellé existe', () => { let charge = chargeNeuveEpreuve(); for (const entree of REGLAGES_GENERATION) { - const valeur = entree.genre === 'bascule' ? !entree.defaut : entree.defaut + 1; + const valeur = autreValeur(entree); const [commande, args] = ECRITURES.get(entree.section)(entree, valeur); charge = appliquerCommande(commande, charge, args).charge; - assert.equal(valeurDuReglage(charge.reglages, entree), valeur, entree.cle); + assert.deepEqual(valeurDuReglage(charge.reglages, entree), valeur, entree.cle); assert.notEqual(t(entree.etiquette), entree.etiquette); } assert.deepEqual([...new Set(REGLAGES_GENERATION.map(({ section }) => section))], [...ECRITURES.keys()]); @@ -173,6 +255,18 @@ describe('valeurDuReglage, reglagesDuGenre et reglagesDeSection', () => { const cible = REGLAGES_GENERATION.find(({ nom }) => nom === 'cibleRedondance'); assert.equal(valeurDuReglage({ generation: {} }, cible), CIBLE_REDONDANCE_PAR_DEFAUT); assert.equal(valeurDuReglage({ generation: {} }, { ...cible, defaut: 3 }), 3); + for (const entree of REGLAGES_GENERATION.filter(({ genre }) => genre !== 'champ')) { + assert.equal(valeurDuReglage({ generation: {} }, entree), entree.defaut, entree.cle); + } + }); + + test('le mode et l’ordre se lisent à la racine des réglages, comme les cases facultatives', () => { + const lire = (nom, reglages) => valeurDuReglage(reglages, REGLAGES_GENERATION.find((entree) => entree.nom === nom)); + const ordre = [[{ critere: 'redondance', poids: 1 }]]; + assert.equal(lire('modeObjectifs', { modeObjectifs: 'minimum' }), 'minimum'); + assert.equal(lire('priorites', { priorites: ordre }), ordre); + assert.equal(lire('respecterObjectifs', { respecterObjectifs: false }), false); + assert.equal(lire('rencontrerAnimateurs', { rencontrerAnimateurs: true }), true); }); test('un genre inconnu lève, et le nomme', () => { @@ -187,7 +281,7 @@ describe('valeurDuReglage, reglagesDuGenre et reglagesDeSection', () => { ); assert.deepEqual( reglagesDeSection(definition, 'generation').slice(0, 2).map(({ nom }) => nom), - ['attribuerSieges', 'varierAppartenances'], + ['priorites', 'modeObjectifs'], ); assert.deepEqual(reglagesDuGenre([], 'bascule'), []); assert.deepEqual(reglagesDeSection([], 'generation'), []); diff --git a/src/interface/import/Apercu.svelte b/src/interface/import/Apercu.svelte index ead324f..6eaed12 100644 --- a/src/interface/import/Apercu.svelte +++ b/src/interface/import/Apercu.svelte @@ -2,14 +2,17 @@ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
    @@ -65,6 +74,9 @@ {#if !global.remede.dansLaCause}

    {t(global.remede.cle, global.remede.details)}

    {/if} + {#if remedeGlobal !== null} + + {/if}
    {/if} @@ -115,7 +127,7 @@ {t('import.apercu.ligne')} - {#each COLONNES_DES_LIGNES as { champ, cle } (champ)} + {#each modele.champs as { champ, cle } (champ)} {t(cle)} {/each} @@ -124,8 +136,18 @@ {#each modele.lignes as { ligne, cellules } (ligne)} {format.nombre(ligne)} - {#each cellules as { champ, texte, cle } (champ)} - {cle === null ? (texte ?? '') : t(cle)} + {#each cellules as { champ, texte, cle, etiquettes } (champ)} + + {#if etiquettes !== null} + {#if etiquettes.length > 0} +
      + {#each etiquettes as motCle (motCle)} +
    • + {/each} +
    + {/if} + {:else}{cle === null ? (texte ?? '') : t(cle)}{/if} + {/each} {/each} @@ -189,6 +211,27 @@ {/if} + {#if modele.fusionsEtiquettes.length > 0} + +
      + {#each modele.fusionsEtiquettes as fusion (fusion.retenue)} +
    • + {t('import.apercu.retenue')} + {fusion.retenue} + {t('import.apercu.orthographes')} +
        + {#each fusion.orthographes as orthographe (orthographe)} +
      • {orthographe}
      • + {/each} +
      + {format.nombre(fusion.lignes)} + {t('import.apercu.lignesReecrites', { n: fusion.lignes })} +
    • + {/each} +
    +
    + {/if} + {#if modele.doublons.length > 0}
      @@ -324,6 +367,13 @@ text-align: right; } + /* Un motif cite la valeur fautive, qu'aucun blanc ne coupe parfois — une + étiquette trop longue — : il passe à la ligne n'importe où plutôt que + d'élargir la page. */ + .refusees td { + overflow-wrap: anywhere; + } + .defile { max-height: 24rem; overflow: auto; @@ -351,6 +401,26 @@ padding-left: var(--espace-5); } + /* Les orthographes d'une étiquette fondue, en ligne, séparées d'un blanc. */ + .orthographes { + display: inline-flex; + flex-wrap: wrap; + gap: var(--espace-2); + padding: 0; + list-style: none; + } + + /* Les étiquettes d'une cellule, une Pastille chacune, celle de la fiche : + neutre, à l'icône des étiquettes. La cellule passe à la ligne entre deux + pastilles. */ + .etiquettes { + display: flex; + flex-wrap: wrap; + gap: var(--espace-1); + padding: 0; + list-style: none; + } + .personne { display: inline-flex; gap: var(--espace-1); diff --git a/src/interface/import/Import.navigateur.test.js b/src/interface/import/Import.navigateur.test.js index 7afed18..ef3c33a 100644 --- a/src/interface/import/Import.navigateur.test.js +++ b/src/interface/import/Import.navigateur.test.js @@ -1,11 +1,13 @@ // © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -// L'import des participants dans un vrai moteur de rendu (§ 10.1, § 10.2, -// § 10.3, § 2.1 étape 2, § 9) : l'aperçu obligatoire, l'encodage et le +// L'import des participants et des objectifs de table dans un vrai moteur de +// rendu (§ 10.1, § 10.2, § 10.3, § 10.4, § 2.1 étape 2, § 9) : la nature +// choisie, jamais devinée, l'aperçu obligatoire, l'encodage et le // séparateur nommés, l'association par en-tête, l'ambiguïté, les refus -// d'une ligne et les refus globaux, le rapport, le CSV des refus, les trois -// modes, l'import selon l'état, le collage. Chaque épreuve monte App par +// d'une ligne et les refus globaux, les étiquettes en pastilles et leurs +// fusions à part, le rapport, le CSV des refus, les modes, l'import selon +// l'état, le collage. Chaque épreuve monte App par // monterApp, sur le système de fichiers simulé, ouvre un événement et sa // section d'import ; le dialogue de choix sert, dans l'ordre, les fichiers // que l'épreuve prépare, et le dialogue d'enregistrement relève ce qu'il @@ -29,17 +31,19 @@ import { apercevoir } from '../../csv/apercu.js'; import { decoder } from '../../csv/encodage.js'; import { bilanRemplacement, exporterRefus as exporterRefusCsv } from '../../csv/import.js'; import { decouper } from '../../csv/lecture.js'; +import { apercevoirObjectifs, bilanRemplacementObjectifs } from '../../csv/objectifs.js'; import { serialiser } from '../../stockage/canonique.js'; import { creerCharge } from '../../stockage/document.js'; import { lireJournal } from '../../stockage/journal.js'; import { SUFFIXES } from '../../stockage/noms.js'; import { VERSION } from '../../version.genere.js'; -import { pointer } from '../../../test/navigateur/entrees.js'; +import { pointer, touche } from '../../../test/navigateur/entrees.js'; import { monterApp } from '../../../test/navigateur/montage.js'; import { composantes, visible } from '../../../test/navigateur/rendu.js'; import urlCasLimites from '../../../exemples/participants_cas_limites.csv?url'; import urlCasLimites1252 from '../../../exemples/participants_cas_limites_cp1252.csv?url'; import urlGrande from '../../../exemples/participants_demo_grande.csv?url'; +import urlObjectifs from '../../../exemples/objectifs_cas_limites.csv?url'; import { TABLES_INTERFACE } from '../libelles.js'; import { ENCODAGES, SEPARATEURS } from './modele.js'; @@ -93,10 +97,12 @@ afterEach(() => { * dialogue de choix sert aServir dans l'ordre ; l'enregistreur relève * enregistres et rend le nom proposé. */ -async function ouvrir({ participants = [], ecriture = true, langue = 'fr', siegesParDefaut = 6, prepare = null } = {}) { +async function ouvrir({ participants = [], tables = [], ecriture = true, langue = 'fr', siegesParDefaut = 6, prepare = null } = {}) { const charge = creerCharge({ id: 'epreuve-import', nom: 'Soirée des Merles', siegesParDefaut, tours: 2 }); charge.participants = participants; charge.prochainsIds.participant = participants.length + 1; + charge.tables = tables; + charge.prochainsIds.table = tables.length + 1; const texte = serialiser(charge, { revision: 1, produitVersion: VERSION.affichee }); const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `${BASE}.gtt.json`, texte }], langue }); montes.push(m); @@ -123,7 +129,7 @@ const tous = (e, selecteur) => [...section(e).querySelectorAll(selecteur)]; const commande = (e, nom) => dans(e, `[data-commande="${nom}"]`); const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim(); const ecritures = ({ m }) => m.trace.length; -const imports = ({ espion }) => espion.mock.calls.filter(([nom]) => nom === 'importerParticipants'); +const imports = ({ espion }, nom = 'importerParticipants') => espion.mock.calls.filter(([appel]) => appel === nom); // Sert un fichier au dialogue et le choisit : l'aperçu s'ouvre sur lui. async function choisir(e, nom, octets) { @@ -146,13 +152,13 @@ function choisirMode(e, mode) { flushSync(); } -// Clique « Importer » et attend la fin de l'appel à la séance qu'il fait : -// rend ce que rend seance.executer, ou le refus. -async function importer(e) { - const avant = imports(e).length; +// Clique « Importer » et attend la fin de l'appel à la séance qu'il fait, +// la commande nom : rend ce que rend seance.executer, ou le refus. +async function importer(e, nom = 'importerParticipants') { + const avant = imports(e, nom).length; commande(e, 'importer').click(); - await vi.waitFor(() => expect(imports(e).length).toBe(avant + 1)); - const resultat = e.espion.mock.results.filter((_, rang) => e.espion.mock.calls[rang][0] === 'importerParticipants').at(-1); + await vi.waitFor(() => expect(imports(e, nom).length).toBe(avant + 1)); + const resultat = e.espion.mock.results.filter((_, rang) => e.espion.mock.calls[rang][0] === nom).at(-1); try { const rendu = await resultat.value; await vi.waitFor(() => expect(dans(e, '[data-rapport]')).not.toBeNull()); @@ -164,12 +170,18 @@ async function importer(e) { } // Les lignes valides montrées sous un conteneur : le texte de chaque -// cellule, dans l'ordre. +// cellule, dans l'ordre. Une cellule d'étiquettes se lit pastille par +// pastille, chacune par son libellé, sans l'espace que la Pastille pose +// entre son icône et lui. +const texteCellule = (cellule) => { + const pastilles = [...cellule.querySelectorAll('li')]; + return pastilles.length === 0 ? cellule.textContent : pastilles.map(texteDe).join(''); +}; const lignesMontrees = (conteneur) => [...conteneur.querySelectorAll('[data-lignes] [data-ligne]')].map((ligne) => - [...ligne.querySelectorAll('[data-cellule]')].map((cellule) => cellule.textContent)); + [...ligne.querySelectorAll('[data-cellule]')].map(texteCellule)); const cellule = (conteneur, ligne, champ) => - conteneur.querySelector(`[data-lignes] [data-ligne="${ligne}"] [data-cellule="${champ}"]`).textContent; + texteCellule(conteneur.querySelector(`[data-lignes] [data-ligne="${ligne}"] [data-cellule="${champ}"]`)); const refusee = (conteneur, ligne) => conteneur.querySelector(`[data-refusees] [data-ligne="${ligne}"]`); const association = (conteneur) => [...conteneur.querySelectorAll('[data-colonne]')].map((colonne) => [ @@ -213,6 +225,17 @@ function vu(element) { return visible(element); } +// La couleur qu'un jeton donne dans le thème courant, relue sur une sonde. +function jeton(nom) { + const sonde = document.createElement('span'); + sonde.style.color = `var(${nom})`; + document.body.append(sonde); + const couleur = composantes(getComputedStyle(sonde).color); + sonde.remove(); + return couleur; +} +const fondDe = (element) => composantes(getComputedStyle(element).backgroundColor); + describe('l’aperçu est obligatoire (§ 10.1)', () => { test('choisir les cas limites ouvre l’aperçu, sans rien écrire ; déposer un fichier sur la fenêtre ouvre son aperçu ; aucun contrôle hors de l’import n’importe', async () => { const e = await ouvrir(); @@ -911,16 +934,6 @@ describe('les cas du § 10.3', () => { }); describe('l’habillage : cartes et messages classés en refus, avis ou information', () => { - // La couleur qu'un jeton donne dans le thème courant, relue sur une sonde. - function jeton(nom) { - const sonde = document.createElement('span'); - sonde.style.color = `var(${nom})`; - document.body.append(sonde); - const couleur = composantes(getComputedStyle(sonde).color); - sonde.remove(); - return couleur; - } - const fondDe = (element) => composantes(getComputedStyle(element).backgroundColor); const nomDe = (region) => texteDe(document.getElementById(region.getAttribute('aria-labelledby'))); // La classe d'un message, lue dans son bloc : la pastille dont le texte @@ -941,9 +954,11 @@ describe('l’habillage : cartes et messages classés en refus, avis ou informat exigerSorte(bloc('[data-refusees]'), 'refus', `${lieu}, lignes refusées`); exigerSorte(bloc('[data-doublons]'), 'avis', `${lieu}, doublons`); exigerSorte(bloc('[data-fusions]'), 'information', `${lieu}, orthographes fondues`); + exigerSorte(bloc('[data-fusions-etiquettes]'), 'information', `${lieu}, étiquettes fondues`); + expect(bloc('[data-fusions-etiquettes]'), lieu).not.toBe(bloc('[data-fusions]')); } - test('les cas limites : refusées en refus, doublons en avis, fusions en information, dans l’aperçu puis le rapport', async () => { + test('les cas limites : refusées en refus, doublons en avis, fusions d’appartenances et d’étiquettes en information, chacune son bloc, dans l’aperçu puis le rapport', async () => { const e = await ouvrir(); await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites)); exigerBlocs(dans(e, '[data-apercu]'), 'aperçu'); @@ -976,6 +991,21 @@ describe('l’habillage : cartes et messages classés en refus, avis ou informat exigerSorte(rien, 'information', 'rapport, rien d’écrit'); }); + // Un import d'objectifs n'écrit rien quand chaque couple du fichier porte + // déjà son objectif et qu'il refuse des lignes : ici, la table 1 porte + // déjà « finance », et aucune table n'affiche le numéro 9. + test('un import d’objectifs qui n’a rien écrit le dit en information, avec le texte des objectifs', async () => { + const e = await ouvrir({ tables: [table(1, 1, [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }]), table(2, 2)] }); + choisirNature(e, 'objectifs'); + await choisir(e, 'inchange.csv', enUtf8('table;objectif\r\n1;finance\r\n9;finance\r\n')); + const { rendu } = await importer(e, 'importerObjectifs'); + expect(rendu.libelle).toBeNull(); + const rapport = dans(e, '[data-rapport]'); + const rien = [...rapport.querySelectorAll('section')].find((bloc) => texteDe(bloc).includes(t('import.rapport.rienEcritObjectifs'))) ?? null; + exigerSorte(rien, 'information', 'rapport des objectifs, rien d’écrit'); + expect(texteDe(rapport)).not.toContain(t('import.rapport.rienEcrit')); + }); + test('un refus global porte la pastille du refus, sa cause et son remède', async () => { const e = await ouvrir(); await coller(e, 'Ombrelle\tIris\nPervenche\tThéo'); @@ -997,3 +1027,363 @@ describe('l’habillage : cartes et messages classés en refus, avis ou informat expect(regions()).not.toContain(t('import.apercu.titre')); }); }); + +// --- La nature du fichier, les objectifs de table, les étiquettes (§ 10.1, § 10.4) --- + +// Une table de la charge d'épreuve, ses objectifs donnés. +const table = (id, numero, objectifs = null) => ({ + id, numero, sieges: null, forme: 'ronde', position: { x: 0, y: 0 }, ...(objectifs === null ? {} : { objectifs }), +}); +// Neuf tables numérotées 1, 2, 3, 3, puis 4 à 8 : deux portent le numéro 3. +const NEUF_TABLES = () => [1, 2, 3, 3, 4, 5, 6, 7, 8].map((numero, rang) => table(rang + 1, numero)); + +// Le CSV des objectifs qui refuse une ligne pour chaque motif du § 10.4, +// dans l'ordre du texte ; huit lignes valides précèdent le champ en trop, +// que l'analyse du séparateur ne lit qu'au-delà de ses vingt premiers +// enregistrements. +const VINGT_ET_UNE = Array.from({ length: 21 }, (_, rang) => `mot${rang + 1}`).join('|'); +const TEXTE_DES_MOTIFS = [ + 'table;tour;objectif;regle', + '1;;finance;', + ';;finance;', + 'un;;finance;', + '9;;finance;', + '3;;finance;', + '2;2.0;finance;', + '2;5;finance;', + '2;1;;', + `2;1;${'x'.repeat(41)};`, + `2;1;${VINGT_ET_UNE};`, + '2;1;finance;peut-être', + '1;;Finance;et', + '2;2;FINANCE;toutes', + '4;;bénévole;ou', + '4;1;bénévole;', + '4;2;bénévole;', + '5;;bénévole;', + '5;1;bénévole;', + '5;2;bénévole;', + '6;;bénévole;', + '6;1;bénévole;une', + '1;1;finance;;en trop', + '', +].join('\r\n'); +const MOTIFS_DU_10_4 = [ + 'TABLE_ABSENTE', 'TABLE_NON_ENTIERE', 'NUMERO_TABLE_INCONNU', 'PLUSIEURS_TABLES', 'TOUR_NON_ENTIER', 'TOUR_HORS_SOIREE', + 'OBJECTIF_VIDE', 'ETIQUETTE_MAL_FORMEE', 'ETIQUETTES_TROP_NOMBREUSES', 'REGLE_INCONNUE', 'OBJECTIF_EN_DOUBLE', 'CHAMPS_EN_TROP', +]; + +// Coche une nature du fichier. +function choisirNature(e, nature) { + dans(e, `[data-nature-import] input[value="${nature}"]`).click(); + flushSync(); +} +const natureCochee = (e) => dans(e, '[data-nature-import] input:checked')?.value ?? null; + +// Les étiquettes montrées en pastilles dans une cellule de l'aperçu. +const pastilles = (conteneur, ligne, champ) => + [...conteneur.querySelectorAll(`[data-lignes] [data-ligne="${ligne}"] [data-cellule="${champ}"] li`)].map(texteDe); + +// Un premier geste, sans effet sur les objectifs ni sur les tables : le +// journal existe avant l'import, dont l'entrée se compte seule. +const premierGeste = (seance) => seance.executer('changerRegleObjectifs', { regle: 'et' }); + +// Les comptes du rapport d'un import d'objectifs, affichés puis attendus. +const COMPTES_DES_OBJECTIFS = [ + ['ajoutes', 'import.rapport.objectifsAjoutes'], + ['remplaces', 'import.rapport.objectifsRemplaces'], + ['inchanges', 'import.rapport.objectifsInchanges'], + ['retires', 'import.rapport.objectifsRetires'], + ['tables', 'import.rapport.tablesTouchees'], +]; +const comptesObjectifsAffiches = (e) => + COMPTES_DES_OBJECTIFS.map(([nom]) => { + const compte = dans(e, `[data-rapport] [data-compte="${nom}"]`); + return [nom, compte.querySelector('[data-nombre]').textContent, compte.querySelector('[data-nombre] + span').textContent]; + }); +const comptesObjectifsDu = (resume) => + COMPTES_DES_OBJECTIFS.map(([nom, cle]) => [nom, format.nombre(resume[nom]), t(cle, { n: resume[nom] })]); + +describe('la nature du fichier : choisie avant l’aperçu, jamais devinée (§ 10.4)', () => { + test('Participants est cochée par défaut, avant tout fichier ; le CSV des objectifs livré, lu sous Participants, est refusé sans commande d’import ; coché Objectifs, le même texte se lit en objectifs', async () => { + const e = await ouvrir({ tables: NEUF_TABLES() }); + expect(dans(e, '[data-apercu]')).toBeNull(); + const choix = dans(e, '[data-nature-import]'); + expect(vu(choix)).toBe(true); + expect(natureCochee(e)).toBe('participants'); + expect([...choix.querySelectorAll('label')].map(texteDe)).toEqual([t('import.nature.participants'), t('import.nature.objectifs')]); + expect(texteDe(choix.querySelector('legend'))).toBe(t('import.nature.titre')); + + const octets = await lireLivre(urlObjectifs); + await choisir(e, 'objectifs_cas_limites.csv', octets); + expect(natureCochee(e)).toBe('participants'); + expect(dans(e, '[data-apercu] [data-refus="AUCUN_ENTETE"]')).not.toBeNull(); + expect(commande(e, 'importer')).toBeNull(); + expect(tous(e, '[data-apercu] [data-champ="mode"]')).toEqual([]); + + choisirNature(e, 'objectifs'); + expect(natureCochee(e)).toBe('objectifs'); + expect(dans(e, '[data-apercu] [data-fichier]').textContent).toBe('objectifs_cas_limites.csv'); + expect(dans(e, '[data-apercu] [data-refus]')).toBeNull(); + const apercu = apercevoirObjectifs(decoder(octets).texte, e.m.seance.etat().charge); + expect(association(dans(e, '[data-apercu]'))).toEqual([ + ['Table', t('import.champ.table')], + ['Tour', t('import.champ.tour')], + ['Objectif', t('import.champ.objectif')], + ['Règle', t('import.champ.regle')], + ]); + expect(tous(e, '[data-apercu] [data-lignes] [data-ligne]').map((ligne) => Number(ligne.dataset.ligne))).toEqual( + apercu.lignes.map(({ ligne }) => ligne), + ); + expect(tous(e, '[data-apercu] [data-refusees] [data-ligne]').map((ligne) => Number(ligne.dataset.ligne))).toEqual( + apercu.refusees.map(({ ligne }) => ligne), + ); + expect([...dans(e, '[data-champ="mode"]').querySelectorAll('input')].map((input) => input.value)).toEqual(['mettreAJour', 'remplacer']); + expect(dans(e, '[data-champ="mode"] input:checked').value).toBe('mettreAJour'); + expect(commande(e, 'importer')).not.toBeNull(); + }); + + test('une liste de participants qui porte une colonne « Table », lue sous Participants : la table ignorée, des personnes ; sous Objectifs, refusée faute d’objectif', async () => { + const e = await ouvrir({ tables: NEUF_TABLES() }); + await choisir(e, 'invites.csv', enUtf8('nom;prenom;table\r\nOmbrelle;Iris;2\r\nPervenche;Théo;1\r\n')); + expect(association(dans(e, '[data-apercu]')).at(-1)).toEqual(['table', t('import.colonne.nonReconnue')]); + expect(lignesMontrees(dans(e, '[data-apercu]')).map(([nom]) => nom)).toEqual(['Ombrelle', 'Pervenche']); + choisirNature(e, 'objectifs'); + expect(dans(e, '[data-apercu] [data-refus="OBJECTIF_NON_ASSOCIE"]')).not.toBeNull(); + expect(texteDe(dans(e, '[data-apercu] [data-refus]'))).toContain(t('import.remede.objectifNonAssocie')); + expect(commande(e, 'importer')).toBeNull(); + choisirNature(e, 'participants'); + expect(dans(e, '[data-apercu] [data-refus]')).toBeNull(); + expect(dans(e, '[data-champ="mode"] input:checked').value).toBe('ajouter'); + }); + + test('au clavier : la flèche passe de Participants à Objectifs de table, l’aperçu ouvert se relit, et le bouton qui a le focus porte l’anneau de l’application', async () => { + const e = await ouvrir({ tables: NEUF_TABLES() }); + await choisir(e, 'objectifs_cas_limites.csv', await lireLivre(urlObjectifs)); + dans(e, '[data-nature-import] input[value="participants"]').focus(); + await touche('ArrowDown'); + await vi.waitFor(() => expect(natureCochee(e)).toBe('objectifs')); + const actif = document.activeElement; + expect(actif.value).toBe('objectifs'); + expect(actif.matches(':focus-visible')).toBe(true); + const style = getComputedStyle(actif); + expect(style.outlineStyle).toBe('solid'); + const sonde = document.createElement('span'); + sonde.style.color = 'var(--couleur-focus)'; + document.body.append(sonde); + expect(style.outlineColor).toBe(getComputedStyle(sonde).color); + sonde.remove(); + await vi.waitFor(() => expect(dans(e, '[data-apercu] [data-refus]')).toBeNull()); + expect(commande(e, 'importer')).not.toBeNull(); + }); + + test('un collage de la grille coche Participants, quelle que soit la nature cochée', async () => { + const e = await ouvrir({ tables: NEUF_TABLES() }); + choisirNature(e, 'objectifs'); + await coller(e, 'nom\tprenom\nOmbrelle\tIris\n'); + expect(natureCochee(e)).toBe('participants'); + expect(lignesMontrees(dans(e, '[data-apercu]')).map(([nom]) => nom)).toEqual(['Ombrelle']); + }); +}); + +describe('l’aperçu des objectifs de table (§ 10.4)', () => { + test('un refus de chaque motif du § 10.4 rendu dans l’aperçu, à son rang, son motif celui de t ; les lignes valides montrent numéro, tour, étiquettes en pastilles et règle', async () => { + const e = await ouvrir({ tables: NEUF_TABLES() }); + choisirNature(e, 'objectifs'); + await choisir(e, 'motifs.csv', enUtf8(TEXTE_DES_MOTIFS)); + const apercu = apercevoirObjectifs(TEXTE_DES_MOTIFS, e.m.seance.etat().charge); + expect(apercu.refusees.map(({ code }) => code)).toEqual(MOTIFS_DU_10_4); + const ici = dans(e, '[data-apercu]'); + for (const { code, brut, ...details } of apercu.refusees) { + const ligne = refusee(ici, details.ligne); + expect(ligne, code).not.toBeNull(); + expect(vu(ligne), code).toBe(true); + expect(ligne.querySelector('[data-motif]').textContent, code).toBe(t(code, details)); + } + const [{ retenue }] = apercu.fusionsEtiquettes; + expect(lignesMontrees(ici)[0]).toEqual(['1', t('import.tour.soiree'), retenue, t('import.regle.defaut')]); + expect(cellule(ici, 14, 'tour')).toBe('2'); + expect(pastilles(ici, 14, 'objectif')).toEqual([retenue]); + expect(cellule(ici, 14, 'regle')).toBe(t('import.regle.et')); + expect(cellule(ici, 15, 'regle')).toBe(t('import.regle.ou')); + expect(texteDe(dans(e, '[data-apercu] [data-valides]'))).toContain(format.nombre(apercu.lignes.length)); + }); + + test('« finance » et « FINANCE » fondus en une orthographe : le bloc des étiquettes fondues, en information, nomme la retenue, les orthographes et les lignes réécrites ; aucun bloc d’appartenances', async () => { + const e = await ouvrir({ tables: NEUF_TABLES() }); + choisirNature(e, 'objectifs'); + await choisir(e, 'motifs.csv', enUtf8(TEXTE_DES_MOTIFS)); + const [fusion] = apercevoirObjectifs(TEXTE_DES_MOTIFS, e.m.seance.etat().charge).fusionsEtiquettes; + const bloc = dans(e, '[data-apercu] [data-fusions-etiquettes]'); + expect(vu(bloc)).toBe(true); + expect(bloc.querySelector('[data-affichee]').textContent).toBe(fusion.retenue); + expect([...bloc.querySelectorAll('[data-orthographe] [data-texte]')].map((o) => o.textContent)).toEqual(fusion.orthographes); + expect(bloc.querySelector(':scope > li > [data-nombre]').textContent).toBe(format.nombre(fusion.lignes)); + expect(texteDe(bloc.closest('section'))).toContain(t('import.apercu.fusionsEtiquettes')); + expect(texteDe(bloc.closest('section'))).toContain(t('import.sorte.information')); + expect(dans(e, '[data-apercu] [data-fusions]')).toBeNull(); + }); + + test('un événement sans table : OBJECTIFS_SANS_TABLE, et son remède ouvre la section des tables', async () => { + const e = await ouvrir(); + choisirNature(e, 'objectifs'); + await choisir(e, 'objectifs_cas_limites.csv', await lireLivre(urlObjectifs)); + const pose = dans(e, '[data-apercu] [data-refus="OBJECTIFS_SANS_TABLE"]'); + expect(vu(pose)).toBe(true); + expect(texteDe(pose)).toContain(t('OBJECTIFS_SANS_TABLE')); + const remede = pose.querySelector('[data-commande="ouvrirSection"]'); + expect(remede.textContent.trim()).toBe(t('communs.remede.ouvrirSection.tables')); + expect(commande(e, 'importer')).toBeNull(); + remede.click(); + await vi.waitFor(() => expect(e.m.ui.etat().section).toBe('tables')); + }); + + test('chaque refus global propre aux objectifs : sa cause et son remède, aucune commande d’import, rien d’écrit', async () => { + const e = await ouvrir({ tables: NEUF_TABLES() }); + choisirNature(e, 'objectifs'); + const avant = ecritures(e); + const cas = [ + ['AUCUN_ENTETE', 'a;b\r\n1;finance\r\n', 'import.remede.aucunEnteteObjectifs'], + ['TABLE_NON_ASSOCIEE', 'tour;objectif\r\n1;finance\r\n', 'import.remede.tableNonAssociee'], + ['OBJECTIF_NON_ASSOCIE', 'table;tour\r\n1;1\r\n', 'import.remede.objectifNonAssocie'], + ['AUCUNE_LIGNE_VALIDE', 'table;objectif\r\n9;finance\r\n', 'import.remede.aucuneLigneValide'], + ]; + for (const [code, texte, remede] of cas) { + await choisir(e, `${code}.csv`, enUtf8(texte)); + const pose = dans(e, `[data-apercu] [data-refus="${code}"]`); + expect(pose, code).not.toBeNull(); + expect(texteDe(pose), code).toContain(t(remede)); + expect(commande(e, 'importer'), code).toBeNull(); + expect(natureCochee(e), code).toBe('objectifs'); + } + expect(ecritures(e)).toBe(avant); + }); +}); + +describe('importer des objectifs de table (§ 10.4)', () => { + test('mettre à jour : une entrée, importerObjectifs sous l’aperçu montré ; le rapport porte les comptes du résumé et le bloc des étiquettes fondues ; le CSV des refus est celui d’exporterRefus', async () => { + const e = await ouvrir({ tables: NEUF_TABLES(), prepare: premierGeste }); + choisirNature(e, 'objectifs'); + await choisir(e, 'motifs.csv', enUtf8(TEXTE_DES_MOTIFS)); + const apercu = apercevoirObjectifs(TEXTE_DES_MOTIFS, e.m.seance.etat().charge); + commande(e, 'enregistrerRefus').click(); + await vi.waitFor(() => expect(e.enregistres.length).toBe(1)); + expect(e.enregistres[0].nomPropose).toBe(`${BASE}-refus.csv`); + expect([...e.enregistres[0].octets]).toEqual([...exporterRefusCsv(apercu, libelle)]); + + const entreesAvant = entrees(e); + const { rendu } = await importer(e, 'importerObjectifs'); + expect(imports(e)).toEqual([]); + const [[, argumentsRecus]] = imports(e, 'importerObjectifs'); + expect(argumentsRecus.mode).toBe('mettreAJour'); + expect(argumentsRecus.apercu).toEqual(apercu); + expect(entrees(e)).toBe(entreesAvant + 1); + expect(rendu.resume).toMatchObject({ ajoutes: 10, remplaces: 0, inchanges: 0, retires: 0 }); + expect(comptesObjectifsAffiches(e)).toEqual(comptesObjectifsDu(rendu.resume)); + const rapport = dans(e, '[data-rapport]'); + expect(vu(rapport.querySelector('h3'))).toBe(true); + expect(rapport.querySelector('[data-fusions-etiquettes]')).not.toBeNull(); + expect(rapport.querySelectorAll('[data-refusees] [data-ligne]').length).toBe(MOTIFS_DU_10_4.length); + const tables = e.m.seance.etat().charge.tables; + const [{ retenue }] = apercu.fusionsEtiquettes; + expect(tables.find(({ id }) => id === 1).objectifs).toEqual([{ portee: 'tous', tour: null, regle: null, etiquettes: [retenue] }]); + + // L'avis de A20 : l'événement n'a aucun participant, donc aucun des dix + // objectifs écrits n'a de correspondant. La commande le rend, la séance + // le relaie, la liste des avertissements de l'événement le montre. + expect(rendu.avertissements.map(({ code }) => code)).toEqual(Array(10).fill('OBJECTIF_SANS_CORRESPONDANT')); + const montres = [...e.m.racine.querySelectorAll('[data-avertissement="OBJECTIF_SANS_CORRESPONDANT"]')]; + expect(montres.map((avis) => texteDe(avis.querySelector('p')))).toEqual(rendu.avertissements.map(({ code, details }) => t(code, details))); + expect(vu(montres[0])).toBe(true); + }); + + test('remplacer annonce ses deux comptes, objectifs et tables ; Annuler n’écrit rien ; Confirmer, une entrée, et les objectifs que le fichier ne nomme pas partent', async () => { + const soiree = (etiquettes) => ({ portee: 'tous', tour: null, regle: null, etiquettes }); + const tables = [ + table(1, 1, [soiree(['bénévole']), { portee: 'tour', tour: 2, regle: 'et', etiquettes: ['Montréal', 'finance'] }]), + table(2, 2, [soiree(['ressources humaines'])]), + table(3, 3), + ]; + const e = await ouvrir({ tables, prepare: premierGeste }); + choisirNature(e, 'objectifs'); + await choisir(e, 'remplace.csv', enUtf8('table;objectif\r\n3;finance\r\n')); + choisirMode(e, 'remplacer'); + const bilan = bilanRemplacementObjectifs(e.m.seance.etat().charge); + expect(bilan).toEqual({ objectifs: 3, tables: 2 }); + const attendus = [t('import.remplacer.objectifs', { n: 3 }), t('import.remplacer.tables', { n: 2 })]; + expect(attendus).toEqual(['3 objectifs de table seront supprimés', '2 tables perdront leurs objectifs']); + const avant = ecritures(e); + commande(e, 'importer').click(); + await vi.waitFor(() => expect(dans(e, 'dialog')?.open).toBe(true)); + expect(texteDe(dans(e, 'dialog'))).toContain(t('import.remplacer.titreObjectifs')); + expect([...dans(e, 'dialog').querySelectorAll('li')].map((li) => li.textContent)).toEqual(attendus); + dans(e, 'dialog [data-commande="renoncer"]').click(); + await vi.waitFor(() => expect(dans(e, 'dialog')).toBeNull()); + expect(imports(e, 'importerObjectifs')).toEqual([]); + expect(ecritures(e)).toBe(avant); + + const entreesAvant = entrees(e); + commande(e, 'importer').click(); + await vi.waitFor(() => expect(dans(e, 'dialog')?.open).toBe(true)); + dans(e, 'dialog [data-commande="confirmer"]').click(); + await vi.waitFor(() => expect(dans(e, '[data-rapport]')).not.toBeNull()); + expect(imports(e, 'importerObjectifs').map(([, { mode }]) => mode)).toEqual(['remplacer']); + expect(entrees(e)).toBe(entreesAvant + 1); + const { resume } = await e.espion.mock.results.at(-1).value; + expect(resume).toMatchObject({ ajoutes: 1, remplaces: 0, inchanges: 0, retires: 3, tables: 3 }); + expect(comptesObjectifsAffiches(e)).toEqual(comptesObjectifsDu(resume)); + expect(e.m.seance.etat().charge.tables.map(({ objectifs = [] }) => objectifs.length)).toEqual([0, 0, 1]); + }); + + test('bloqué : l’aperçu des objectifs s’ouvre, l’import est refusé à côté de la commande, rien d’écrit', async () => { + const e = await ouvrir({ tables: NEUF_TABLES(), prepare: (seance) => seance.executer('changerEtat', { etat: 'bloque' }) }); + choisirNature(e, 'objectifs'); + const avant = ecritures(e); + await choisir(e, 'objectifs_cas_limites.csv', await lireLivre(urlObjectifs)); + const { refus } = await importer(e, 'importerObjectifs'); + expect(refus.code).toBe('LECTURE_SEULE'); + expect(commande(e, 'importer').parentElement.contains(dans(e, '[data-refus="LECTURE_SEULE"]'))).toBe(true); + expect(ecritures(e)).toBe(avant); + }); +}); + +describe('les étiquettes des participants dans l’aperçu (§ 10.1, A17)', () => { + test('les cas limites : chaque cellule d’étiquettes en pastilles, sous l’orthographe retenue ; le bloc des étiquettes fondues présent, à part des appartenances, dans l’aperçu puis le rapport', async () => { + const e = await ouvrir(); + const octets = await lireLivre(urlCasLimites); + await choisir(e, 'participants_cas_limites.csv', octets); + const apercu = apercevoir(decoder(octets).texte); + expect(apercu.fusionsEtiquettes.length).toBeGreaterThanOrEqual(1); + const ici = dans(e, '[data-apercu]'); + for (const { ligne, champs } of apercu.lignes) expect(pastilles(ici, ligne, 'etiquettes'), `ligne ${ligne}`).toEqual(champs.etiquettes ?? []); + expect(apercu.lignes.some(({ champs }) => (champs.etiquettes ?? []).length > 1)).toBe(true); + // Chaque étiquette, la pastille de la fiche : neutre, à icône, de + // l'encre de la page ; son fond se détache de celui de la cellule. + const elements = [...ici.querySelectorAll('[data-cellule="etiquettes"] li')]; + expect(elements.length).toBeGreaterThan(1); + for (const li of elements) { + const pastille = [li, ...li.querySelectorAll('*')].find((element) => fondDe(element)[3] > 0); + expect(fondDe(pastille), texteDe(li)).toEqual(jeton('--couleur-neutre-fond')); + expect(li.querySelector('svg'), texteDe(li)).not.toBeNull(); + expect(composantes(getComputedStyle(pastille).color), texteDe(li)).toEqual(jeton('--couleur-page-texte')); + } + expect(vu(elements[0])).toBe(true); + expect(fondDe(elements[0].closest('td'))).not.toEqual(jeton('--couleur-neutre-fond')); + + for (const lieu of ['[data-apercu]', '[data-rapport]']) { + if (lieu === '[data-rapport]') await importer(e); + const conteneur = dans(e, lieu); + const bloc = conteneur.querySelector('[data-fusions-etiquettes]'); + expect(bloc, lieu).not.toBeNull(); + expect([...bloc.querySelectorAll('[data-affichee]')].map((a) => a.textContent), lieu).toEqual( + apercu.fusionsEtiquettes.map(({ retenue }) => retenue), + ); + const appartenances = conteneur.querySelector('[data-fusions]'); + expect(appartenances.contains(bloc), lieu).toBe(false); + expect(bloc.contains(appartenances), lieu).toBe(false); + const retenues = new Set(apercu.fusionsEtiquettes.map(({ retenue }) => retenue)); + for (const affichee of appartenances.querySelectorAll('[data-affichee]')) expect(retenues.has(affichee.textContent), lieu).toBe(false); + expect(appartenances.querySelectorAll('[data-fusion]').length, lieu).toBe(apercu.fusions.length); + } + }); +}); diff --git a/src/interface/import/Rapport.svelte b/src/interface/import/Rapport.svelte index d7295f2..7d44cd6 100644 --- a/src/interface/import/Rapport.svelte +++ b/src/interface/import/Rapport.svelte @@ -2,40 +2,40 @@ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) --> @@ -43,10 +43,10 @@
      {#if rapport.libelle === null} -

      {t('import.rapport.rienEcrit')}

      +

      {t(RIEN_ECRIT.get(rapport.nature))}

      {/if}
        - {#each COMPTES as [nom, cle] (nom)} + {#each comptes as { nom, cle } (nom)}
      • {format.nombre(rapport.resume[nom])} {t(cle, { n: rapport.resume[nom] })} diff --git a/src/interface/import/SectionImport.svelte b/src/interface/import/SectionImport.svelte index 937bcde..e46a5d1 100644 --- a/src/interface/import/SectionImport.svelte +++ b/src/interface/import/SectionImport.svelte @@ -2,24 +2,32 @@ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->