From 999485890455eee1f900c3290f8d4f5c113244a3 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 9 Oct 2026 17:14:56 -0400 Subject: [PATCH] [ADD] docs: usage and Windows guides, with how the search works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An operator who did not write the software must be able to prepare an evening alone. GUIDE-USAGE.md follows an evening step by step on the published captures, with the seed and stop count that reproduce them, and explains why two searches give two placements and how the ranking picks the best one. GUIDE-WINDOWS.md covers installation and the portable mode. README.md carries the generated contrast and constant tables, and a delivery build fails when a generated region differs. Checked: the guide's numbers are bounded in the tests; 3699 node and 101 long tests green from the index alone. --- FR --- [ADD] docs : guides d'usage et Windows, fonctionnement de la recherche Une personne qui n'a pas écrit le logiciel doit pouvoir préparer une soirée seule. GUIDE-USAGE.md suit une soirée pas à pas sur les captures publiées, avec la graine et le compte d'arrêt qui les reproduisent, et explique pourquoi deux recherches donnent deux placements et comment le classement choisit le meilleur. GUIDE-WINDOWS.md couvre l'installation et le mode portable. README.md porte les tableaux engendrés des contrastes et des constantes, et une construction de livraison échoue quand une région diffère. Vérifié : les nombres du guide sont bornés dans les épreuves ; 3699 node et 101 longues vertes depuis l'index seul. Assisted-by: Claude Opus 5.5 --- GUIDE-USAGE.md | 604 ++++++++++++++++++++++++++++++++++++ GUIDE-WINDOWS.md | 181 +++++++++++ README.md | 84 +++++ package.json | 2 +- scripts/documentation.js | 6 +- test/documentation.test.js | 618 +++++++++++++++++++++++++++++++++++++ vitest.config.js | 4 +- 7 files changed, 1495 insertions(+), 4 deletions(-) create mode 100644 GUIDE-USAGE.md create mode 100644 GUIDE-WINDOWS.md create mode 100644 test/documentation.test.js diff --git a/GUIDE-USAGE.md b/GUIDE-USAGE.md new file mode 100644 index 0000000..15adf72 --- /dev/null +++ b/GUIDE-USAGE.md @@ -0,0 +1,604 @@ +# Gestion table tournante Libre — le guide d’usage + +Ce guide suit une soirée du début à la fin : ouvrir un événement, y mettre les +participants et les tables, laisser le logiciel chercher des placements, en +retenir un, l’ajuster, puis imprimer. Il s’adresse à la personne qui prépare la +soirée ; il ne suppose rien de la façon dont le logiciel est écrit. + +Les écrans montrés ici se reproduisent. Le parcours crée un événement nommé +« Soirée d’essai », y importe le fichier `exemples/participants_cas_limites.csv` +livré avec le logiciel, pose deux tables, désigne un animateur et génère avec la +graine 4242 et un compte d’arrêt de 300, les autres réglages de génération +gardant leur valeur par défaut ; les étapes où la taille compte chargent la +« Grande démonstration » depuis le premier écran. Suivez les mêmes gestes, avec +les mêmes réglages, et vous verrez les mêmes écrans. + +Les étapes sont **un ordre de lecture, pas un assistant** : le logiciel +n’enchaîne rien et ne vous oblige jamais à revenir au début. Une faute de +saisie découverte au moment d’imprimer se corrige à l’endroit où elle se trouve. + +Pour installer et lancer le logiciel sous Windows, et savoir où vont vos +fichiers, lisez `GUIDE-WINDOWS.md`. + +## Avant de commencer : lecture, écriture, et trois garanties + +**Tout événement s’ouvre en lecture.** Rien ne s’y modifie tant que vous n’avez +pas cliqué sur « Modifier », toujours à la même place, en haut de l’écran. Un +cadre autour du plan et le bandeau disent dans quel mode vous êtes. Après un +moment d’inactivité, le logiciel revient seul en lecture, entre deux gestes et +jamais au milieu de l’un d’eux. + +**Un plan bloqué** refuse toute modification, import compris, jusqu’à ce que +vous le débloquiez. L’état du plan — « Brouillon », « Proposé », « Retenu », +« Bloqué » — se lit et se change en haut de l’événement. + +Tout geste qui modifie l’événement suit trois règles : + +1. une fois achevé, il est **à la fois** dans l’historique et sur le disque ; + il n’y a jamais rien à enregistrer ; +2. il se reprend : « Défaire » et « Refaire » remontent et redescendent + l’historique, qui ne perd jamais rien ; +3. s’il est refusé, le refus dit pourquoi et, quand un geste le lève, lequel — + à l’endroit même où vous avez agi : sur la place, sur la ligne de l’aperçu + d’import, sur le champ. Seul un geste qui détruit de la saisie ouvre une + fenêtre de confirmation. + +Lire le diagnostic et comparer les propositions ne modifient rien : ces écrans +restent utilisables en lecture. + +## Étape 1 — Ouvrir ou créer l’événement + +Le premier écran est la liste des événements. Quand elle est vide, elle n’est +jamais une page blanche : elle offre de créer un événement et de charger l’une +des démonstrations livrées. Elle affiche aussi le dossier de travail, avec la +commande qui l’ouvre dans l’explorateur. + +![Le premier écran : le dossier de travail, la création d’un événement, les démonstrations](doc/captures/01-premier-ecran.png) + +*Regardez la partie « Démonstrations » : chacune se charge comme un nouvel +événement, ouvert en lecture, sans toucher aux autres. Sous « Créer un +événement », seul le nom est exigé ; la date est facultative.* + +Ouvrir un événement existant ne réécrit jamais son fichier. Créer un événement, +ou charger une démonstration, en écrit un nouveau. + +## Étape 1b — Passer en écriture + +L’événement s’ouvre en lecture. Tentez un geste qui modifie, par exemple +« Ajouter une table ronde » dans la section « Tables et tours » : le refus +s’affiche à côté du bouton, et propose « Modifier ». + +![Un ajout de table refusé en lecture, et la commande « Modifier »](doc/captures/01b-passer-en-ecriture.png) + +*Le refus apparaît là où vous avez cliqué, sans fenêtre. Le mot « Lecture » +figure dans le bandeau et sur le cadre de l’événement.* + +Cliquez sur « Modifier » : le cadre passe en écriture, et le même geste +réussit. « Revenir en lecture » fait l’inverse. + +## Étape 2 — Importer la liste des participants + +Cette étape est facultative : la liste peut aussi se saisir à la main +(étape 3). Dans la section « Import », choisissez un fichier CSV ou déposez-le +sur la fenêtre. **Rien ne s’importe avant l’aperçu.** + +![L’aperçu d’un import : encodage, séparateur, colonnes reconnues](doc/captures/02-import-apercu.png) + +*L’aperçu nomme l’encodage et le séparateur que le logiciel a reconnus, dit ce +que devient chaque colonne — « Organisation » devient l’appartenance, « Rôle » +le titre pressenti — et signale une colonne non reconnue, ignorée. Vérifiez ces +trois lignes avant d’importer.* + +Le mode de l’import se choisit au même endroit : « Ajouter » ajoute des +personnes, « Mettre à jour » met à jour celles que le fichier reconnaît sans +effacer les champs qu’il ne porte pas, « Remplacer » remplace la liste et +annonce d’abord ce qu’il détruit. + +Un fichier enregistré par un tableur sous Windows n’est souvent pas en UTF-8. +Le logiciel le reconnaît et le dit : + +![L’aperçu du même fichier enregistré en windows-1252 : les accents intacts](doc/captures/02b-import-cp1252.png) + +*Ici l’encodage lu est « windows-1252 » et le séparateur la virgule. Vérifiez +un nom accentué : « Givrecœur » et « Héloïse » doivent s’afficher tels quels. Un +prénom écrit « Héloïse » au lieu de « Héloïse » trahit un encodage mal lu, et +rien d’autre ne vous en avertirait.* + +Après l’import, le rapport compte ce qui est entré : + +![Le rapport d’import : personnes ajoutées, orthographes fondues, lignes refusées](doc/captures/03-import-rapport.png) + +*Le rapport compte les personnes ajoutées, mises à jour, inchangées ou +retirées, regroupe les orthographes d’une même appartenance qu’il a fondues, et +liste les lignes refusées avec leur motif. « Enregistrer les lignes refusées » +les écrit dans un fichier, pour les corriger dans un tableur et les réimporter. +L’import entier compte pour un seul geste : « Défaire » le reprend d’un coup.* + +Un import est refusé sur un plan bloqué ; le message nomme la commande qui +débloque. + +## Étape 3 — Compléter la liste à la main + +Dans la section « Participants », la grille se modifie directement. Seul le nom +est exigé ; le logiciel n’invente aucune valeur manquante. La ligne « Ajouter +une personne » ajoute quelqu’un ; une plage de cellules copiée d’un tableur et +collée dans la grille passe par l’aperçu d’import. + +![La grille des participants, triée par nom, avec une personne exclue](doc/captures/04-grille-participants.png) + +*Le compte des participants égale celui de l’import. La personne marquée +« exclue » reste dans la liste mais n’est plus placée, et ne compte plus dans +aucune statistique : « Réintégrer » la remet. Cliquez sur un en-tête de colonne +pour trier, tapez dans « Filtrer » pour réduire la liste.* + +« Exporter la liste » écrit la grille dans un fichier CSV. L’export écrit +chaque valeur telle qu’elle a été saisie, sans rien y ajouter : un tableur qui +l’ouvre peut prendre pour une formule une cellule qui commence par « = », +« + », « - » ou « @ ». Si une valeur commence ainsi, ouvrez le fichier par la +commande d’import de texte du tableur, colonnes en texte. + +## Étape 4 — Préparer les tables + +Dans la section « Tables et tours », fixez d’abord les **places par défaut**, +puis ajoutez les tables, rondes ou carrées. Chaque table suit le défaut, ou +porte son propre nombre de places : on dit alors qu’elle est surchargée. + +![Deux tables de huit places : l’une suit le défaut, l’autre est surchargée](doc/captures/05-tables-etat.png) + +*Les deux tables ont le même nombre de places, mais l’une « Suit le défaut » et +l’autre est « Surchargée » : seule la première suivra un changement du défaut. +« Réaligner » ramène une table surchargée au défaut.* + +Changer le défaut ne s’applique pas tout de suite : le logiciel annonce d’abord +la conséquence, chiffrée. + +![L’annonce d’un changement de défaut, avant application](doc/captures/06-defaut-consequence.png) + +*L’annonce dit combien de tables suivent le défaut et ce qu’elles deviennent, +lesquelles, surchargées, ne changent pas, et le total des sièges contre le +nombre de participants. Choisissez « Appliquer », « Appliquer et réaligner la +surchargée » ou « Garder le défaut actuel ». Une baisse qu’une seule table +refuse est refusée en entier.* + +La section « Plan » montre les tables à l’échelle : + +![Le plan des tables, leur échelle graphique et la mention « disposition relative »](doc/captures/07-plan-tables.png) + +*Chaque table se dessine à sa taille réelle, avec ses sièges numérotés. Tant +qu’aucune dimension de salle n’est saisie, la mention « disposition relative » +rappelle que l’encombrement dans la salle n’est pas vérifié. Faites glisser une +table pour la déplacer ; « Ajuster » cadre le plan entier.* + +Sur un grand plan, le dessin s’adapte au zoom. Zoomé, chaque siège se vise : + +![La grande démonstration zoomée : chaque siège est une cible](doc/captures/07a-cibles-sieges.png) + +*À ce zoom, un clic ou un dépôt vise le siège sous le pointeur.* + +Au zoom qui montre toute la salle, les sièges deviennent trop petits pour être +visés un par un, et c’est la table entière qui reçoit le geste : + +![La grande démonstration au zoom ajusté : chaque table est une cible](doc/captures/07a-cibles-tables.png) + +*Ici, un dépôt vise la table ; zoomez pour choisir un siège précis.* + +## Étape 5 — Fixer le nombre de tours + +Le nombre de tours se règle dans la même section. Retirer un tour puis le +remettre retrouve intactes les réservations valables à tous les tours. En +revanche, retirer le tour *r* détruit les réservations posées sur ce seul tour, +et le logiciel le dit **avant** d’appliquer : + +![L’annonce d’un retrait de tour : la réservation qu’il emporte](doc/captures/07b-nombre-de-tours.png) + +*L’annonce nomme chaque réservation détruite, avec sa personne, sa table et +son tour. « Annuler » ne change rien.* + +Les placements déjà générés ne sont pas tronqués : ils restent entiers, mais ne +décrivent plus la soirée, et le logiciel nomme ceux que le changement touche. + +## Étape 6 — Désigner les animateurs, table par table + +Cette étape est facultative : une soirée sans animateur est valide. Le +logiciel ne répartit pas les animateurs : vous désignez vous-même **quelle +personne anime quelle table**. + +Choisissez la personne — dans la « Réserve » du plan, ou par « Voir sur le +plan » depuis la grille —, puis la place : « Désigner » pose d’un seul geste une +réservation valable à tous les tours et un titre de place, par exemple +« animation ». Les deux ensemble font l’animateur. « Réserver » pose une +réservation seule, pour un tour ou pour tous ; « Donner un titre » pose un +titre seul, que personne n’occupe encore. + +![Un animateur désigné, un titre non pourvu, et les deux listes de travail](doc/captures/08-animateurs-designes.png) + +*À la table 1, le siège 1 porte un cadenas fermé et un anneau : réservé à tous +les tours, à l’animateur. À la table 2, le siège 1 est hachuré avec un cadenas +ouvert : un titre « animation » attend son titulaire. La liste des sièges +énonce les deux en toutes lettres. À droite, les « Listes de travail » montrent +ce qui reste à faire : les titres de place non pourvus, et les personnes au +titre pressenti qui ne sont réservées nulle part.* + +Une table peut avoir zéro, un ou plusieurs animateurs, mais jamais plus +d’animateurs que de sièges. Une table dont tous les sièges sont réservés est +acceptée et signalée : elle ne tourne plus. Quand les sièges ne sont pas +attribués, « la place » est la table entière, qui porte un compte de titres. + +## Étape 7 — Placer des participants à la main + +Les mêmes gestes posent des réservations ordinaires, pour un tour ou pour tous. +Le logiciel refuse de déposer quelqu’un sur une place réservée à un autre, et +dit pourquoi sur la place même : + +![Un dépôt refusé sur une place réservée, et le geste qui le lèverait](doc/captures/09-refus-sur-place.png) + +*Le refus s’affiche sur le siège visé, nomme sa cause — le siège est réservé à +une autre personne à tous les tours — et le geste qui la lève : déplacer cette +réservation. Aucune fenêtre ne s’ouvre, rien ne s’écrit.* + +Déplacer une réservation est un geste à part : « Déplacer sa réservation », +dans le menu de la personne. + +## Étape 8 — Lire le diagnostic + +Le diagnostic se calcule seul, avant toute recherche : il n’y a rien à +demander. Il dit ce que la configuration rend inévitable, combien, et ce qui le +rendrait évitable. + +![Le diagnostic de la grande démonstration](doc/captures/10-diagnostic.png) + +*Sur la grande démonstration, le diagnostic donne le **plafond a priori** — le +plus de personnes qu’on puisse rencontrer, ici différent pour les animateurs des +petites tables et pour les autres —, les **retours imposés** par les +réservations, comptés à part de ceux que choisit la recherche, et le +**plancher de l’écart d’itinéraire**. Chaque ligne dit ce qui la ferait bouger, +et ce qu’aucune relance ne changera.* + +Le même diagnostic s’affiche à côté du plan, pendant que vous préparez les +tables : chaque appartenance trop nombreuse pour ses tables y annonce les +retrouvailles qu’elle imposera. + +## Étape 9 — Générer + +Dans la section « Génération », réglez au besoin la **graine**, le nombre de +**propositions par génération** et le **compte d’arrêt**, puis cliquez sur +« Générer ». Les réservations des étapes 6 et 7 sont honorées ; la recherche +place les autres personnes autour. + +Les propositions montrées ci-dessous, et les écrans qui en découlent aux +étapes 10 et 11, viennent d’une génération faite avec la graine 4242 et un +compte d’arrêt de 300 ; les autres réglages gardent leur valeur par défaut. +Le compte d’arrêt par défaut, bien plus grand, cherche plus longtemps et donne +d’autres propositions : écrivez ces deux valeurs pour retrouver les mêmes. + +Les **cœurs de calcul** sont automatiques par défaut, un par processeur +logique du poste ; vous pouvez aussi en fixer le nombre. Ce réglage vaut pour la +séance, sur ce poste, et ne s’enregistre pas dans l’événement. Le logiciel +affiche la durée du dernier calcul et estime celle de la prochaine génération ; +une génération qui s’annonce longue demande d’abord confirmation. + +Chaque génération **ajoute** ses propositions aux précédentes, et un seul +classement les ordonne toutes. Le logiciel marque dans l’historique l’instant +qui précède chaque génération, pour y revenir. + +![Les propositions classées, l’ordre du classement écrit au-dessus](doc/captures/11-propositions.png) + +*Le tableau range les propositions selon l’ordre écrit au-dessus de lui : +d’abord l’écart au plafond a priori, puis l’excédent de collisions, et ainsi +de suite, chaque critère au plus faible d’abord. Les colonnes donnent, pour +chacune, le moins de personnes distinctes rencontrées, les collisions entre +personnes d’une même appartenance, les rencontres répétées et la diversité des +appartenances rencontrées.* + +## Étape 10 — Comparer, et retenir + +Comparer se fait dans le [tableau classé](doc/captures/11-propositions.png) de +la section « Propositions ». La première ligne est la meilleure selon le +classement ; les colonnes du **manque** — « Plus grand manque », « Personnes à +qui il en manque », « Écart d’itinéraire maximal » — permettent de comparer deux +propositions même quand des tables sont incomplètes (voir « Comment le logiciel +cherche », plus bas). « Imposé par les réservations » sépare ce que vos +réservations imposent de ce que la recherche a choisi. + +« Voir sur le plan » montre une proposition sur le plan, sans rien écrire. +« Retenir » en fait le **placement retenu** : l’état du plan passe à +« Retenu », et les autres propositions restent dans la liste. Retenir une autre +proposition alors que le retenu a été ajusté à la main détruit ces ajustements : +une fenêtre le dit avant. + +La liste des événements résume ensuite le retenu par ses collisions cumulées et +par l’écart de sa somme de redondance d’appartenance à la cible de la soirée. + +Retenir n’est pas obligatoire, mais tout ce qui s’imprime sans placement retenu +porte la mention BROUILLON (étape 12). + +## Étape 11 — Ajuster à la main + +Sur le placement retenu, le geste de l’étape 7 déplace maintenant une personne +réelle, au tour affiché. **Le geste est le même, ce qu’il change ne l’est pas, +et l’interface le dit** pendant que vous faites glisser : + +![Un déplacement dans le placement retenu, annoncé pendant le glissement](doc/captures/19-ajustement.png) + +*Pendant le glissement, l’étiquette qui suit le pointeur dit « déplace dans le +placement retenu » : la personne change de place dans le plan qui sera +imprimé, sans poser de réservation. Le titre du plan, « Placement retenu », +dit quel placement vous regardez.* + +Les indicateurs du tableau des propositions suivent l’ajustement. + +En lecture, aucun de ces gestes ne passe : + +![Le plan en lecture : le cadre, son libellé, et un geste refusé](doc/captures/20-mode-lecture.png) + +*Le cadre et le libellé « Lecture » entourent le plan, et le bandeau le répète. +Glisser une personne ou une table, tirer une poignée, poser une réservation : +chaque geste est refusé avec « Modifier » pour remède, et rien ne change sur le +disque.* + +## Étape 12 — Imprimer + +La commande « Imprimer… », en haut de l’événement, ouvre l’**aperçu +d’impression**, qui montre les pages telles qu’elles sortiront : le **plan** +de salle, la **liste d’accueil** et la **feuille par table**. Le plan se +découpe en pages quand il ne tient pas lisiblement sur une seule ; chaque page +porte son repère et ses voisines. Au premier aperçu, le logiciel demande le +format du papier, A4 ou Lettre, puis le garde pour ce poste ; il se change +ensuite à tout moment. + +La **planche de badges** est le seul document produit en PDF : quatre badges +par feuille A4, deux par feuille Lettre, avec leurs traits de coupe. Sa +première page est une **feuille de contrôle** : mesurez son échelle sous une +règle avant d’imprimer toute la rame, pour vérifier que l’imprimante n’a pas +réduit la page. Les badges se tirent du placement retenu, ou d’une proposition +choisie. + +Imprimer et produire la planche ne modifient pas l’événement : ces commandes +restent disponibles en lecture et sur un plan bloqué. Le logiciel n’interdit +pas d’imprimer un plan qui n’est pas retenu, mais il le marque **BROUILLON** +en filigrane ; une planche tirée d’une proposition l’est toujours. Chaque page +imprimée porte en pied le nom et la date de l’événement, le tour, l’instant où +le placement a pris sa forme, et la version du logiciel. + +Une fois le plan imprimé, bloquez-le : choisissez l’état « Bloqué » en haut de +l’événement. Plus rien ne le modifie, jusqu’à « Débloquer ». + +**Ce qui doit avoir eu lieu.** Il faut des tables (étape 4) et au moins une +personne (étape 2 ou 3) avant de générer ; il faut avoir retenu un placement +(étape 10) pour qu’une impression ne sorte pas en brouillon. Tout le reste est +facultatif, et se reprend dans n’importe quel ordre. + +## Comment le logiciel cherche + +Cette section répond à deux questions : pourquoi deux propositions issues du +même plan diffèrent, et pourquoi relancer aide parfois, et parfois jamais. + +### Le problème + +Asseoir chacun, à chaque tour, de sorte que la personne la plus mal servie +rencontre le plus de monde possible. + +### Pourquoi il n’a pas de réponse évidente + +Prenez les douze personnes de la petite démonstration et ses quatre tables +numérotées de trois places. Pour un seul tour, il y a 369 600 façons de les +répartir : on compte toutes les façons d’aligner les douze personnes, puis on +cesse de distinguer l’ordre des trois personnes d’une même table. Sur quatre +tours, le nombre dépasse 10²². Le logiciel n’essaie donc pas toutes les +combinaisons — et la petite démonstration est déjà le plus petit cas qu’il +livre. + +### Comment la recherche avance + +Le logiciel part d’un placement complet, dont le point de départ dépend de la +**graine**. Il **échange** des personnes entre les tables, garde ce qui ne +dégrade pas le placement, et s’arrête après un **compte** de mouvements +essayés — le compte d’arrêt de la section « Génération » —, ou plus tôt s’il +ne reste rien à améliorer. + +D’où la réponse à la première question : deux recherches parties de deux +points différents s’arrêtent sur deux placements différents, et aucun des deux +n’est « le bon ». La même graine, sur la même configuration et les mêmes +réglages, redonne exactement les mêmes propositions ; une autre graine en donne +d’autres. Cette phrase est vérifiée par une épreuve du logiciel. + +### Ce que font les contraintes + +« Séparer les appartenances », « Nouveaux voisins à chaque tour », « Nouvelle +table à chaque tour », « Varier les appartenances rencontrées » sont des +**objectifs**, pas des interdits. Quand ils ne tiennent pas tous ensemble, le +logiciel produit le meilleur compromis et dit lequel, au lieu de refuser et de +ne rien montrer. + +### Le plafond + +Dans la petite démonstration, chacun a deux voisins par tour : en quatre tours, +il rencontre au plus 8 personnes, sur onze personnes qu’il pourrait rencontrer. +Personne ne peut faire mieux, et ce n’est pas un défaut du logiciel. À une +table de huit, chacun a sept voisins : en quatre tours, 28 rencontres au plus. +Dans la grande démonstration, les quatre animateurs des tables de sept restent +à leur table toute la soirée, et leur plafond vaut 24. Le diagnostic affiche +ces plafonds avant toute recherche +([le diagnostic de la grande démonstration](doc/captures/10-diagnostic.png)) : +**la relance ne déplace jamais un plafond.** + +### Ce que « minimum atteint » veut dire + +La colonne « Certificat » du tableau des propositions écrit « minimum atteint » +quand chaque personne rencontre autant de monde que son plafond a priori le +permet : personne ne peut faire mieux, et relancer ne sert plus à rien. Le +minimum se mesure contre le plafond a priori, pas contre ce qu’un placement +donné rendait possible. C’est pourquoi la grande démonstration ne l’obtient +jamais : son diagnostic prouve, avant toute recherche, que des personnes +finiront sous leur plafond a priori — c’est le plancher de l’écart +d’itinéraire. + +Son absence ne dit pas qu’un meilleur placement existe. « Atteignabilité +inconnue » dit que le logiciel ne sait pas si mieux est possible. + +### Ce que relancer ne peut pas + +Relancer n’efface pas les collisions que le diagnostic annonce comme +inévitables — les « au moins » de chaque appartenance trop nombreuse pour ses +tables —, et ne crée pas une table qui n’existe pas. Le diagnostic dit **avant** +la recherche ce qui est hors d’atteinte, et quel changement de configuration le +lèverait : une table de plus, un tour de moins, une appartenance mieux +répartie. + +### Comparer deux propositions + +Comparez par le **manque** : pour chaque personne, l’écart entre ce que son +placement lui permettait de rencontrer et ce qu’elle rencontre. Les nombres +bruts ne se comparent plus dès qu’une table est incomplète : qui s’assied à +cette table rencontre moins de monde, et cela change d’une proposition à +l’autre. + +Trois colonnes le disent ([le tableau classé](doc/captures/11-propositions.png)). +« Plus grand manque » : de combien la personne la plus privée l’est-elle, compte +tenu de là où ce placement l’a assise. « Personnes à qui il en manque » : une +seule personne, ou quarante ? Les deux appellent des décisions opposées — +déplacer quelqu’un, ou ajouter une table. « Écart d’itinéraire maximal » : la +proposition a-t-elle perdu avant même que chacun s’asseye, en envoyant +quelqu’un à des tables incomplètes ou trop souvent aux mêmes tables ? + +C’est la troisième colonne qui évite le contresens : une personne laissée en +réserve pendant presque toute la soirée a un manque nul, puisqu’elle n’avait +presque personne à rencontrer. Un manque nul sur un itinéraire bas n’est pas +une bonne nouvelle. + +### L’écart entre les propositions d’une génération + +Quand la meilleure et la moins bonne proposition d’une même génération +atteignent le même minimum, c’est le plus souvent qu’une borne le tient, et +relancer n’y changera rien. C’est **un indice, pas une preuve**. Le plafond a +priori, que le diagnostic affiche, le dit d’un coup d’œil : quand le minimum +d’une proposition l’égale, c’est la borne qui le tient, et aucune relance ne +fera mieux. + +## Annexe A — Les réglages et leurs valeurs par défaut + +Les réglages que l’événement enregistre, l’écran qui les montre et leur valeur +à la création d’un événement. Les cœurs de calcul n’y figurent pas : c’est un +réglage du poste, pour la séance, automatique par défaut (étape 9). + + + +| Réglage | Écran | Valeur par défaut | +|---|---|---| +| Propositions par génération (nombre de propositions) | Génération | 5 | +| Compte d'arrêt (mouvements évalués par proposition) | Génération | 200 000 | +| Historique d'acceptation (longueur, en mouvements) | Génération | 1 000 | +| Séparer les appartenances | Génération | cochée | +| 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 | +| Attribuer les sièges | Génération | décochée | +| Cible de la somme de redondance | Propositions | 1 000 | + + + +## Annexe B — Les colonnes reconnues à l’import + +Chaque colonne que l’import reconnaît, sous son en-tête d’export, et les autres +intitulés qu’il accepte pour elle. Les intitulés se comparent sans égard aux +majuscules ni aux accents. + + + +| Colonne | Synonymes reconnus | +|---|---| +| `nom` | — | +| `prenom` | — | +| `appartenance` | `organisation`, `entreprise`, `équipe` | +| `courriel` | — | +| `titre_pressenti` | `titre`, `rôle` | +| `exclu` | — | +| `notes` | — | + + + +## Annexe C — Les commandes et leur trace dans l’historique + +Chaque geste qui modifie l’événement, et la ligne qu’il laisse dans +l’historique ; les noms des exemples sont inventés. + + + +| Commande | Libellé d'historique (exemple) | +|---|---| +| `ajouterParticipant` | Ajouté Iris Ombrelle | +| `modifierParticipant` | Modifié Ondine Lacasse : courriel | +| `exclureParticipant` | Exclu Théo Pervenche | +| `reintegrerParticipant` | Réintégré Théo Pervenche | +| `supprimerParticipant` | Supprimé Ondine Lacasse | +| `importerParticipants` | Importé 12 participants | +| `changerEtat` | Plan passé à l'état « Bloqué » | +| `debloquer` | Plan débloqué, état « Retenu » | +| `enregistrerGeneration` | Généré 5 propositions (n° 3 à 7) | +| `retenirProposition` | Retenu la proposition n° 3 | +| `effacerPropositions` | Effacé 4 propositions, gardé la retenue (n° 2) | +| `creerTable` | Ajouté la table 3 | +| `supprimerTable` | Supprimé la table 2 ; retirés : 2 réservations et 1 titre | +| `deplacerTable` | Déplacé la table 3 | +| `changerPlacesTable` | Table 5 : 10 places | +| `realignerTable` | Table 5 : suit le défaut (8 places) | +| `changerDefaut` | Sièges par défaut : 10 ; 4 tables réalignées | +| `changerTours` | Nombre de tours : 3 ; 2 réservations retirées | +| `modifierReglage` | Séparer les appartenances : désactivé | +| `changerCibleRedondance` | Cible de la somme de redondance : 1200 | +| `reserver` | Réservé Théo Pervenche à la table 4, siège 2, tour 2 | +| `retirerReservation` | Réservation retirée : Théo Pervenche, table 4, tous les tours | +| `deplacerReservation` | Réservation déplacée : Théo Pervenche, de la table 4 à la table 7, tour 2 | +| `poserTitre` | Posé le titre « accueil » à la table 3, siège 1 | +| `retirerTitre` | Retiré le titre « accueil » de la table 3, siège 1 | +| `designer` | Désigné Iris Ombrelle à la table 3, siège 1 : « accueil » | +| `convertirEnTous` | Converti en « tous les tours » : Iris Ombrelle, table 3 | +| `deplacerDansRetenu` | Déplacé Iris Ombrelle de la table 4 à la table 7, tour 2 | +| `echangerDansRetenu` | Échangé Iris Ombrelle (table 4) et Théo Pervenche (réserve), tour 2 | +| `retirerDuTour` | Retiré Iris Ombrelle de la table 4, tour 2 | + + + +## Annexe D — Les raccourcis clavier + + + +| Où | Touches | Effet | +|---|---|---| +| Plan | → | Passe à la table voisine à droite, ou au siège suivant. | +| Plan | ↓ | Passe à la table voisine en dessous, ou au siège suivant. | +| Plan | ← | Passe à la table voisine à gauche, ou au siège précédent. | +| Plan | ↑ | Passe à la table voisine au-dessus, ou au siège précédent. | +| Plan | Entrée ou Espace | Descend dans les sièges de la table, ou choisit la place. | +| Plan | Échap | Remonte du siège à sa table ; sur une table, oublie le choix. | +| Grille des participants | → | Passe à la cellule de droite. | +| Grille des participants | ↓ | Passe à la cellule du dessous. | +| Grille des participants | ← | Passe à la cellule de gauche. | +| Grille des participants | ↑ | Passe à la cellule du dessus. | +| Grille des participants | Tab | Valide la saisie en cours et passe à la cellule suivante. | +| Grille des participants | Maj+Tab | Valide la saisie en cours et revient à la cellule précédente. | +| Grille des participants | Entrée ou F2 | Ouvre la saisie de la cellule, ou bascule l'exclusion. | +| Grille des participants | Échap | Efface le refus affiché sous la cellule. | +| Champ de saisie | Entrée | Valide la saisie. | +| Champ de saisie | Échap | Rend la valeur d'avant la saisie. | +| Bouton de commande | Entrée ou Espace | Exécute la commande une fois, même maintenue. | +| Geste en cours | Échap | Annule le geste en cours : la table revient où elle était. | +| Panneau ou infobulle | Échap | Ferme le panneau ou l'infobulle. | + + + +## Annexe E — Les démonstrations livrées + +Les démonstrations que le premier écran offre, lues dans leurs fichiers : leur +taille et l’étendue de leur plafond a priori. + + + +| Démonstration | Effectif | Tables | Tours | Plafond a priori, minimum | Plafond a priori, maximum | +|---|---|---|---|---|---| +| Grande démonstration | 260 | 33 | 4 | 24 | 28 | +| Grande démonstration sans exception | 260 | 33 | 4 | 28 | 28 | +| Petite démonstration | 12 | 4 | 4 | 8 | 8 | +| Petite démonstration, conflit inévitable | 12 | 4 | 4 | 8 | 8 | + + diff --git a/GUIDE-WINDOWS.md b/GUIDE-WINDOWS.md new file mode 100644 index 0000000..13e653d --- /dev/null +++ b/GUIDE-WINDOWS.md @@ -0,0 +1,181 @@ +# Gestion table tournante Libre sous Windows + +Ce guide dit comment obtenir le logiciel, le lancer, où il range vos +événements, comment les sauvegarder, quoi faire si un antivirus le bloque, où +lire sa version et comment signaler un problème. Le déroulement d’une soirée, +de la liste des participants à l’impression, est dans `GUIDE-USAGE.md`. + +Ce guide n’a pas d’images : les écrans qu’il décrit sont ceux de Windows, et les +captures du guide d’usage sont prises ailleurs. + +## Obtenir l’exécutable + +Le logiciel tient en **un seul fichier**, un exécutable portable : il n’y a +rien à installer, et il ne demande aucun droit d’administrateur. Il fonctionne +sans connexion à Internet. + +Vous le recevez de la personne ou de l’organisme qui vous le fournit, sur une +clé, par courriel ou dans un dossier partagé. Son nom suit le gabarit +`gestion_table_tournante_libre_v___.exe` : ``, `` et +`` sont l’année, le mois et le jour de la version, `` son rang dans la +journée. Cherchez donc le fichier dont le nom commence par +`gestion_table_tournante_libre_v` ; ce qui suit change à chaque version, et le +nom ne porte qu’un seul point, celui de `.exe`. + +Rangez l’exécutable dans le dossier où vous voulez garder vos événements, par +exemple un dossier à vous sur le disque ou sur une clé USB : le logiciel +essaie d’écrire à côté de lui (voir « Le dossier de travail »). Évitez de le +lancer directement depuis une pièce jointe ou depuis une archive ouverte sans +l’extraire : Windows le place alors dans un dossier temporaire, et vos +événements iraient dans vos Documents. + +**Passer à une version plus récente.** Lisez d’abord, dans `CHANGELOG.md`, ce +que la nouvelle version change et ce qu’il faut faire en remplaçant. Posez le +nouvel exécutable dans le même dossier que l’ancien : il retrouve le même +dossier de travail et les mêmes événements. Gardez l’ancien tant que la +nouvelle version n’a pas fait ses preuves. Un fichier écrit par une version +plus ancienne s’ouvre normalement, et le bandeau annonce qu’il se convertira à +votre première modification ; un fichier écrit par une version plus récente +s’ouvre en lecture seule. + +## Le lancer + +Double-cliquez sur l’exécutable. Le démarrage prend quelques secondes : le +fichier se décompresse à chaque lancement. Au tout premier lancement, Windows +peut afficher un avertissement ; la section « Si l’antivirus bloque » dit +comment passer. + +La fenêtre s’ouvre sur la **liste des événements**. Elle affiche en clair le +dossier de travail de la séance, sous « Dossier de travail ». Quand aucun +événement n’existe encore, elle offre de créer un événement et de charger l’une +des démonstrations. + +Un événement s’ouvre toujours **en lecture** : rien ne peut le modifier par +mégarde. La commande « Modifier », toujours à la même place, passe en +écriture ; après un moment d’inactivité, le logiciel revient seul en lecture. + +Pour quitter, fermez la fenêtre. Il n’y a rien à enregistrer : chaque geste +achevé est déjà sur le disque. + +## Le dossier de travail + +À chaque lancement, le logiciel choisit son dossier de travail par une règle +fixe, puis l’affiche sur la liste des événements. La règle a **deux issues** : + +1. **Le mode portable : le dossier `data` à côté de l’exécutable.** Le logiciel + crée au besoin un dossier `data` dans le dossier de l’exécutable, puis + vérifie qu’il peut y écrire et relire un fichier. S’il le peut, c’est son + dossier de travail : l’exécutable et vos événements voyagent ensemble, et + copier ce dossier suffit à tout emporter. +2. **Sinon, un dossier dans vos Documents : + `Documents\Gestion table tournante Libre`.** Le logiciel le crée et dit + pourquoi, une fois par lancement, sans rien bloquer. Les raisons possibles : + le dossier de l’exécutable n’est pas connu ; l’exécutable est rangé sous + les données applicatives de Windows ou dans un dossier temporaire ; ou le + dossier `data` refuse l’écriture — une clé verrouillée en lecture seule, un + dossier protégé comme celui des programmes, un antivirus. + +Le logiciel ne range jamais vos événements dans un dossier caché. + +**La commande « Ouvrir dans l’explorateur »**, sous le chemin affiché sur la +liste des événements, ouvre le dossier de travail dans l’Explorateur de +fichiers. + +Ce qui arrive ensuite est réglé aussi : + +- si le dossier `data` contient des événements mais refuse l’écriture, le + logiciel ne bascule pas en silence vers les Documents. Il nomme le dossier, + dit ce qui l’empêche, et vous laisse choisir : « Réessayer », « Travailler + dans les Documents, en y copiant ces événements », ou « Continuer : ouvrir la + liste, aucun geste ne s’y enregistrera » ; +- si le dossier de travail est neuf et vide alors que l’autre emplacement + contient des événements, le logiciel le dit et propose « Importer ces + événements ». L’import les copie : les originaux restent où ils sont ; +- si une écriture échoue en cours de séance, par exemple quand la clé est + retirée, le logiciel nomme le fichier et le dossier, et propose d’écrire + ailleurs. Tant que ce n’est pas fait, le dernier geste n’est pas sur le + disque, et le logiciel le dit ; +- sur une clé ou un disque amovible, le mode portable protège moins bien + contre une coupure pendant une écriture ; le logiciel l’annonce. + +**Ce que contient le dossier.** Chaque événement y tient en deux fichiers +ordinaires, nommés d’après lui : `.gtt.json`, son état complet, et +`.gtt-journal.jsonl`, son historique. Les deux portent le même identifiant +interne, et c’est lui qui les apparie, pas leur nom ; renommez un événement par +la commande « Renommer » de la liste plutôt que dans l’explorateur. Le dossier +`corbeille` reçoit les événements supprimés, chacun dans un dossier daté ; le +logiciel ne la vide jamais. Le fichier `reglages_locaux.json` garde les réglages +de ce poste, comme le format de papier. Les planches de badges produites se +déposent à côté des fichiers de l’événement. + +## Sauvegarder + +Ouvrez le dossier de travail par « Ouvrir dans l’explorateur », puis copiez-le +en entier sur une clé, un autre disque ou un dossier sauvegardé. En mode +portable, copiez le dossier qui contient l’exécutable et son dossier `data`. +Faites la copie logiciel fermé : aucun fichier n’est alors en cours +d’écriture. + +Pour envoyer un seul événement, joignez ses deux fichiers, `.gtt.json` et +`.gtt-journal.jsonl`. Pour le reprendre, déposez-les dans le dossier de +travail, ou utilisez « Importer un événement depuis un fichier… » sur la liste +des événements. Ces fichiers se lisent sans le logiciel : ce sont des fichiers +texte ordinaires. + +Ils contiennent les noms, et parfois les courriels et les notes, des +participants : traitez-les comme toute liste de personnes, et ne les envoyez +qu’à qui doit les recevoir. + +## Si l’antivirus bloque + +**Au premier lancement**, Windows peut afficher « Windows a protégé votre +ordinateur » : l’exécutable est récent et peu répandu, Windows ne le connaît +pas encore. Si le fichier vient bien de la source habituelle, cliquez sur +« Informations complémentaires », puis sur « Exécuter quand même ». En cas de +doute, demandez à la personne qui vous l’a fourni avant de l’ouvrir. + +**Si l’antivirus met l’exécutable en quarantaine** ou l’empêche de démarrer, +demandez à la personne qui gère votre poste de l’autoriser, ou ajoutez-le +vous-même aux exceptions de l’antivirus. + +**Si l’antivirus empêche d’écrire** dans le dossier `data`, le logiciel +travaille dans vos Documents et dit pourquoi. Si la protection de Windows +nommée « Accès contrôlé aux dossiers » est active, elle peut refuser l’écriture +dans les Documents aussi : le logiciel dit alors que le dossier de travail +refuse l’écriture. Ajoutez l’exécutable aux applications autorisées de cette +protection. + +**Si une écriture échoue parce qu’un fichier est occupé**, réessayez : un +antivirus ou un outil de synchronisation de fichiers garde parfois un fichier +ouvert un court instant. + +## Où lire la version + +La version se lit sans rien chercher : + +- dans le **bandeau** en haut de chaque écran, et sur la liste des événements ; +- dans le **titre de la fenêtre**, après le nom du logiciel ; +- au **pied de chaque page imprimée** ; un badge découpé n’en porte pas ; +- dans le panneau « À propos », ouvert depuis le bandeau. Il montre un bloc + d’une ligne — la version, la version Windows, la plateforme, la provenance de + la construction et la version de format des fichiers — que la commande + « Copier » met dans le presse-papiers. + +Les propriétés du fichier, dans l’Explorateur, montrent la **version +Windows**, une autre écriture du même numéro : le bloc d’« À propos » porte les +deux, ce qui permet de faire le lien. + +## Signaler un problème + +Écrivez à la personne ou à l’organisme qui vous a fourni le logiciel, en +joignant : + +1. le bloc d’« À propos », collé tel quel après « Copier » : un numéro recopié + à la main se recopie faux ; +2. ce que vous faisiez, ce que vous attendiez, et ce qui s’est passé, avec le + texte exact du message s’il y en a un ; +3. une capture d’écran qui montre le bandeau : il porte la version ; +4. si le problème tient à un événement précis, et seulement avec l’accord des + personnes concernées, ses deux fichiers. + +Le logiciel n’envoie rien de lui-même : il ne se connecte à aucun service. diff --git a/README.md b/README.md index 31a1a06..426a750 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,90 @@ valables avec un Node de la bonne majeure dans le `PATH` : `npm test`, L'exécutable porte la version datée du logiciel dans son nom, selon le gabarit `gestion_table_tournante_libre_v___.exe`. +## Documentation + +| document | pour qui, pour quoi | +|---|---| +| `GUIDE-USAGE.md` | l'opérateur : le parcours d'une soirée, de la liste des participants à l'impression, écran par écran ; ce que fait la recherche, et ce que relancer peut ou ne peut pas ; en annexe, les réglages, les colonnes du CSV, les commandes, les raccourcis et les démonstrations | +| `GUIDE-WINDOWS.md` | l'opérateur sous Windows : obtenir et lancer l'exécutable, où vont les fichiers, sauvegarder, l'antivirus, la version, signaler un problème | +| `CHANGELOG.md` | ce qui change d'une version à l'autre, et ce qu'il faut faire en remplaçant l'exécutable | + +Les images du guide d'usage sont dans `doc/captures/`, avec leur manifeste. +Les tableaux qui suivent, comme les annexes du guide, sont engendrés : on ne +les corrige pas à la main. `node scripts/documentation.js engendrer` les +réécrit depuis leur source ; `node scripts/documentation.js verifier` échoue +dès qu'un tableau diffère de ce que sa source produirait, et la construction +de livraison le lance. + +## Contrastes + +Le plus petit rapport de contraste relevé pour chaque couleur de l'interface, +dans le rôle où elle sert, sur les captures du guide, en thème clair puis +sombre ; à côté, le seuil que ce rôle exige et la capture où le plus petit +rapport se relève. Un tiret : le thème ne montre pas cette couleur dans ce rôle +sur les captures. + + + +| Jeton | Rôle | Seuil | Thème clair | Capture | Thème sombre | Capture | +|---|---|---|---|---|---|---| +| `--couleur-bandeau-fond` | texte | 4,5 | 14,26 | 01-premier-ecran | 12,84 | 01-premier-ecran | +| `--couleur-bandeau-texte` | texte | 4,5 | 13,44 | 19-ajustement | 12,84 | 01-premier-ecran | +| `--couleur-cadre-ecriture` | marque | 3 | 4,78 | 02-import-apercu | 9,10 | 02-import-apercu | +| `--couleur-cadre-lecture` | marque | 3 | 5,29 | 01b-passer-en-ecriture | 7,34 | 01b-passer-en-ecriture | +| `--couleur-chaise-pleine` | marque | 3 | 15,38 | 07a-cibles-sieges | 14,94 | 07a-cibles-sieges | +| `--couleur-chaise-trait` | marque | 3 | 7,58 | 07-plan-tables | 8,89 | 07-plan-tables | +| `--couleur-conflit-appartenance` | marque | 3 | 8,92 | 19-ajustement | 6,81 | 19-ajustement | +| `--couleur-contour-fort` | marque | 3 | 12,54 | 19-ajustement | 11,62 | 19-ajustement | +| `--couleur-echelle` | marque | 3 | 15,10 | 07-plan-tables | 14,62 | 07-plan-tables | +| `--couleur-fantome` | marque | 3 | 4,34 | 19-ajustement | 4,10 | 19-ajustement | +| `--couleur-hachure` | marque | 3 | 16,48 | 08-animateurs-designes | 15,21 | 08-animateurs-designes | +| `--couleur-marque` | marque | 3 | 3,63 | 07a-cibles-sieges | 3,50 | 07a-cibles-sieges | +| `--couleur-page-texte` | texte | 4,5 | 15,10 | 01-premier-ecran | 11,39 | 19-ajustement | +| `--couleur-page-texte-secondaire` | texte | 4,5 | 7,05 | 10-diagnostic | 7,98 | 10-diagnostic | +| `--couleur-papier` | texte | 4,5 | — | — | 5,33 | 09-refus-sur-place | +| `--couleur-pastille-fond` | marque | 3 | 13,44 | 19-ajustement | 11,62 | 19-ajustement | +| `--couleur-pastille-texte` | texte | 4,5 | — | — | 14,51 | 19-ajustement | +| `--couleur-poignee` | marque | 3 | 7,58 | 07-plan-tables | 11,32 | 07-plan-tables | +| `--couleur-refus` | texte | 4,5 | 8,76 | 01b-passer-en-ecriture | 9,24 | 01b-passer-en-ecriture | +| `--couleur-selection` | marque | 3 | 4,66 | 08-animateurs-designes | 4,18 | 08-animateurs-designes | +| `--couleur-table-trait` | marque | 3 | 7,58 | 07-plan-tables | 5,59 | 07-plan-tables | +| `rgb(0, 0, 0)` | texte | 4,5 | 18,26 | 09-refus-sur-place | — | — | + + + +## Constantes + +Les seuils et les durées dont le logiciel dépend, chacun avec son unité, celui +qui en fixe la valeur — le banc de mesure, un contrôle à la main, ou +l'exécution elle-même —, son statut et la façon de le relever. Une valeur au +« défaut de travail » attend encore son relevé. + + + +| Constante | Valeur | Unité | Propriétaire | Statut | Protocole | +|---|---|---|---|---|---| +| `SEUIL_GLISSER_PX` | 4 | px | manuel | défaut de travail | vingt clics délibérés au pavé tactile de la machine de référence ; le plus grand déplacement entre pointerdown et pointerup, × 1,5, écrit dans ce registre | +| `DUREE_PRESSION_LONGUE_MS` | 500 | ms | manuel | défaut de travail | à l'écran tactile de la machine de référence, la plus courte durée qu'aucun appui bref ne déclenche, sur dix essais, écrite dans ce registre | +| `SEUIL_SIEGES_LISIBLES` | 16 | places | banc | défaut de travail | banc de mesure, cadre de 1 366 × 700 px : le plus grand nombre de places dont la liste, à la hauteur de ligne mesurée, tient dans la hauteur du cadre au zoom ajusté d'une table seule ; écrit dans ce registre | +| `K_LISTES` | 0,3 | px/cm | manuel | défaut de travail | une personne qui n'a pas écrit le logiciel lit les listes de la grande démonstration sur la machine de référence à k décroissant ; le plus petit k où elle les lit sans erreur, écrit dans ce registre | +| `LARGEUR_BLOC_NOMS_CM` | 500 | cm | manuel | défaut de travail | relecture de la grande démonstration au zoom par défaut par une personne qui n'a pas écrit le logiciel (§ 19.11) ; la largeur retenue, écrite dans ce registre | +| `HAUTEUR_LIGNE` | — | cm | execution | mesurée | mesurée à chaque montage du plan : measureText du gabarit « Ôjgq Æ W » sur la police calculée des listes ; aucune valeur n'est écrite dans ce registre | +| `ZONE_SILENCE_MM` | 8 | mm | manuel | défaut de travail | sur chaque imprimante visée : une planche de filets imprimée, son décalage mesuré à la règle, plus l'erreur de dix coupes ; puis sa marge non imprimable et le biais d'entraînement sur dix feuilles. La plus grande des deux bornes, arrondie au millimètre supérieur, écrite dans ce registre | +| `HAUTEUR_CAPITALE_NOM_MAX_MM` | 9 | mm | manuel | défaut de travail | impression réelle d'une planche ; lecture à un mètre par une personne qui n'a pas écrit le logiciel ; la hauteur retenue, écrite dans ce registre | +| `HAUTEUR_CAPITALE_NOM_PLANCHER_MM` | 4 | mm | manuel | défaut de travail | impression réelle d'une planche à hauteurs décroissantes ; lecture à un mètre par une personne qui n'a pas écrit le logiciel ; la plus petite lue sans erreur, écrite dans ce registre | +| `CORPS_TOURS_PT` | 14 | pt | manuel | défaut de travail | impression réelle d'une planche ; lecture en main par une personne qui n'a pas écrit le logiciel ; le corps retenu, écrit dans ce registre | +| `CORPS_TOURS_PLANCHER_PT` | 10 | pt | manuel | défaut de travail | impression réelle d'une planche à corps décroissants ; lecture en main par une personne qui n'a pas écrit le logiciel ; le plus petit corps lu sans erreur, écrit dans ce registre | +| `CORPS_APPARTENANCE_PT` | 12 | pt | manuel | défaut de travail | impression réelle d'une planche ; lecture en main par une personne qui n'a pas écrit le logiciel ; le corps retenu, écrit dans ce registre | +| `CORPS_PIED_PT` | 8 | pt | manuel | défaut de travail | impression réelle d'une planche ; lecture en main par une personne qui n'a pas écrit le logiciel ; le corps retenu, écrit dans ce registre | +| `CORPS_MIN_IMPRESSION_MM` | 2,5 | mm | manuel | défaut de travail | page d'essai à corps décroissants imprimée sur l'imprimante visée, lue à bout de bras ; le plus petit corps lu sans erreur, écrit dans ce registre | +| `MARGE_PAGE_MM` | 10 | mm | manuel | défaut de travail | marge non imprimable mesurée sur chaque imprimante visée (une page de filets à 1 mm d'écart, lue à la règle) ; la plus grande, écrite dans ce registre | +| `HAUTEUR_BANDE_IMPRESSION` | — | mm | execution | mesurée | mesurée à l'ouverture de l'aperçu : tout ce qu'une feuille porte hors de [data-contenu] ; aucune valeur n'est écrite dans ce registre | +| `TOLERANCE_CHAINE_TEMOIN` | 0,01 | rapport | banc | défaut de travail | banc de mesure : le quart de l'écart relatif entre la police embarquée et une famille de repli forcée, et au moins trois fois la gigue de dix mesures ; écrit dans ce registre | +| `RALENTISSEMENT_PLEINE_CHARGE` | 2 | × | banc | défaut de travail | banc de mesure : durée d'une même génération sur un travailleur, puis sur L travailleurs ; le rapport ρ des médianes, écrit dans ce registre | + + + ## Licence © 2026 TechnoLibre. GNU Affero General Public License, version 3 ou ultérieure ; diff --git a/package.json b/package.json index 523d7e3..0ff04d6 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "scripts": { "dev": "vite --mode developpement", "build": "vite build --mode developpement", - "build:livraison": "node scripts/version.js controler && vite build --mode livraison", + "build:livraison": "node scripts/version.js controler && node scripts/documentation.js verifier --livraison && vite build --mode livraison", "build:documentation": "vite build --mode documentation", "test": "vitest run --project node", "test:surveille": "vitest --project node", diff --git a/scripts/documentation.js b/scripts/documentation.js index 74fa76c..6224faa 100644 --- a/scripts/documentation.js +++ b/scripts/documentation.js @@ -74,10 +74,12 @@ export const RACINE = fileURLToPath(new URL('..', import.meta.url)); /** * Les fichiers porteurs de régions, relatifs à la racine : la liste fermée * des documents que verifier et engendrer lisent. Chacun porte au moins une - * région. + * région : README.md les tableaux de mesure (contrastes, constantes), le + * guide d'usage ses annexes. GUIDE-WINDOWS.md n'y figure pas : il décrit des + * écrans qu'aucun scénario ne produit, et ne porte aucune région (§ 16.1). * @type {ReadonlyArray} */ -export const PORTEURS = Object.freeze([]); +export const PORTEURS = Object.freeze(['README.md', 'GUIDE-USAGE.md']); /** Une faute de structure des régions : la liste de ses fautes, par région. */ export class ErreurRegions extends Error { diff --git a/test/documentation.test.js b/test/documentation.test.js new file mode 100644 index 0000000..306e3dd --- /dev/null +++ b/test/documentation.test.js @@ -0,0 +1,618 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves des documents livrés, sur les vrais fichiers (§ 16.1, § 19.7 à +// § 19.9) : GUIDE-USAGE.md suit les étapes du parcours et apparie ses images +// au manifeste et au disque ; les régions engendrées de README.md et du guide +// sont telles qu'engendrer les écrirait ; la section sur l'algorithme cite +// des nombres que l'arithmétique et le diagnostic des démonstrations livrées +// recalculent, et ne cite ni code, ni identifiant exporté, ni module ; +// GUIDE-WINDOWS.md, texte seul, cite le gabarit du nom de l'exécutable et la +// règle du dossier de travail avec ses deux issues. +// +// Chaque contrôle est une fonction qui rend ses fautes : appliquée au vrai +// fichier, elle n'en rend aucune ; appliquée à une copie altérée en mémoire +// (une image retirée, un nombre changé, un nom de fonction collé), elle en +// rend une, ce qui montre qu'elle ne passe pas à vide (§ 14.2). +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { describe, test } from './lanceur.js'; +import { TABLES_APPLICATION } from '../src/application/libelles.js'; +import { creerTraduction } from '../src/application/traduction.js'; +import { CATALOGUE } from '../src/demo/catalogue.js'; +import { LIVREES } from '../src/demo/livrees.js'; +import { TABLES_INTERFACE } from '../src/interface/libelles.js'; +import { diagnostiquer } from '../src/moteur/diagnostic.js'; +import { analyser, configurationDepuisCharge } from '../src/stockage/document.js'; +import { VERSION } from '../src/version.genere.js'; +import { PORTEURS, PRODUCTEURS, RACINE, lireRegions, verifierArbre } from '../scripts/documentation.js'; +import { + A_VENIR, + CAPTURES, + CHEMIN_MANIFESTE, + DOSSIER_CAPTURES, + ETAPES, + REPORTEES, + cheminImage, + cheminsHorsRacine, + imagesReferencees, + verifierAppariement, + verifierOrdre, +} from '../scripts/documentation/manifeste.js'; +import { deriver } from '../scripts/version.js'; + +const GUIDE_USAGE = 'GUIDE-USAGE.md'; +const GUIDE_WINDOWS = 'GUIDE-WINDOWS.md'; +const README = 'README.md'; + +const lire = (chemin) => readFileSync(join(RACINE, chemin), 'utf8'); +const lireManifeste = () => JSON.parse(lire(CHEMIN_MANIFESTE)); + +// Les images PNG du dossier des captures, en chemins relatifs à la racine. +const imagesSurDisque = () => + readdirSync(join(RACINE, DOSSIER_CAPTURES)) + .filter((nom) => nom.endsWith('.png')) + .map((nom) => `${DOSSIER_CAPTURES}/${nom}`); + +const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); + +// --- Le découpage d'un document Markdown ---------------------------------------- + +// Les sections de niveau 2 d'un texte : { titre, debut, texte }, texte allant +// du titre exclu au titre suivant de même niveau ; ce qui précède le premier +// titre n'en est pas une. +function sections(texte) { + const titres = [...texte.matchAll(/^## (.+)$/gm)]; + return titres.map((trouve, rang) => { + const debut = trouve.index + trouve[0].length; + const fin = rang + 1 < titres.length ? titres[rang + 1].index : texte.length; + return { titre: trouve[1], debut, texte: texte.slice(debut, fin) }; + }); +} + +// Le titre de la section d'une étape : « Étape — ». +const TITRE_ETAPE = /^Étape (\S+) \u{2014} \S/u; +// Le titre de la section sur l'algorithme (§ 19.9). +const TITRE_ALGORITHME = 'Comment le logiciel cherche'; + +// Un texte dont chaque suite de blancs, saut de ligne compris, devient une +// espace : une phrase cherchée se trouve quel que soit le retour à la ligne +// du paragraphe qui la porte. +const enLigne = (texte) => texte.replace(/\s+/g, ' '); + +// Le texte où la première occurrence d'une phrase est remplacée, quel que +// soit le retour à la ligne entre ses mots ; échoue si la phrase est absente. +function remplacerPhrase(texte, phrase, remplacement) { + const motif = phrase.split(/\s+/).map((mot) => mot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\s+'); + const remplace = texte.replace(new RegExp(motif, 'u'), remplacement); + assert.notEqual(remplace, texte, `phrase introuvable : ${phrase}`); + return remplace; +} + +// L'espèce d'une faute : ce qui précède son premier « : ». +const especes = (fautes) => fautes.map((faute) => faute.split(' : ')[0]); + +// La section dont le titre est exactement celui donné, ou null. +const sectionNommee = (texte, titre) => sections(texte).find((s) => s.titre === titre) ?? null; + +// --- Les étapes et les images du guide d'usage ------------------------------------ + +/** + * Les fautes du guide d'usage contre les étapes et le manifeste : une section + * par étape d'ETAPES, dans leur ordre, et chaque image citée pour la première + * fois dans la section de l'étape que le manifeste lui déclare. + */ +function fautesEtapes(texte, manifeste) { + const fautes = []; + const etapes = sections(texte) + .map((section) => ({ ...section, etape: TITRE_ETAPE.exec(section.titre)?.[1] ?? null })) + .filter((section) => section.etape !== null); + const lues = etapes.map((s) => s.etape); + if (JSON.stringify(lues) !== JSON.stringify([...ETAPES])) { + fautes.push(`sections d'étape ${JSON.stringify(lues)}, attendues ${JSON.stringify(ETAPES)}`); + } + const etapeDe = new Map(manifeste.captures.map((c) => [cheminImage(c.fichier), c.etape])); + const premiere = new Map(); + for (const section of etapes) { + for (const image of imagesReferencees(section.texte)) { + const vue = texte.indexOf(image); + if (!premiere.has(image) && vue >= section.debut && vue < section.debut + section.texte.length) { + premiere.set(image, section.etape); + } + } + } + for (const image of imagesReferencees(texte)) { + const attendue = etapeDe.get(image); + const lue = premiere.get(image) ?? null; + if (attendue !== undefined && lue !== attendue) { + fautes.push(`${image} : citée d'abord hors de la section de l'étape ${attendue} (${lue ?? 'hors des étapes'})`); + } + } + return fautes; +} + +// Un entier écrit en chiffres comme les documents l'écrivent : des groupes de +// trois séparés par une espace fine insécable. +const enChiffres = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, '\u{202F}'); + +// La graine et le compte d'arrêt que le parcours écrit avant de générer les +// propositions qu'il photographie, lus dans son texte : le guide les donne +// pour que ses écrans se reproduisent (§ 16.1). +function reglagesDuParcours() { + const texte = lire('src/interface/parcours.navigateur.test.js'); + const graine = /^const GRAINE = (\d+);$/m.exec(texte)?.[1]; + const arret = /^const ARRET = (\d+);$/m.exec(texte)?.[1]; + assert.ok(graine !== undefined && arret !== undefined, 'la graine et le compte d’arrêt du parcours'); + return { graine, arret: BigInt(arret) }; +} + +// Les références du guide, telles que verifierAppariement les attend : le +// guide d'usage seul porte des images (GUIDE-WINDOWS.md est texte seul). +const referencesDe = (texteGuide) => [{ document: GUIDE_USAGE, images: imagesReferencees(texteGuide) }]; + +describe('documentation : le guide d’usage suit les étapes et apparie ses images (§ 16.1, § 19.3, § 19.7)', () => { + test('appariement : chaque image du guide est au manifeste et sur le disque, et réciproquement', () => { + const fautes = verifierAppariement({ manifeste: lireManifeste(), references: referencesDe(lire(GUIDE_USAGE)), images: imagesSurDisque() }); + assert.deepEqual(fautes, []); + }); + + test('ordre : le manifeste range ses captures et ses reportées selon les étapes, chaque étape couverte', () => { + assert.deepEqual(verifierOrdre(lireManifeste()), []); + }); + + test('une section par étape, dans l’ordre du parcours ; chaque image citée d’abord sous son étape', () => { + assert.deepEqual(fautesEtapes(lire(GUIDE_USAGE), lireManifeste()), []); + }); + + test('l’introduction et l’étape 9 donnent la graine et le compte d’arrêt du parcours qui photographie', () => { + const { graine, arret } = reglagesDuParcours(); + const texte = lire(GUIDE_USAGE); + const introduction = enLigne(texte.slice(0, texte.indexOf('\n## '))); + const etape9 = enLigne(sections(texte).find((s) => /^Étape 9 \u{2014}/u.test(s.titre))?.texte ?? ''); + for (const [nom, morceau] of [['introduction', introduction], ['étape 9', etape9]]) { + // Chaque nombre est borné à droite : un nombre que le guide prolonge + // ne passe pas pour celui du parcours. + const suivi = (prefixe) => morceau.split(prefixe).slice(1).some((reste) => !/^[\d\u{202F}]/u.test(reste)); + assert.ok(suivi(`la graine ${graine}`), `${nom} : la graine ${graine}`); + assert.ok(suivi(enLigne(`un compte d\u{2019}arrêt de ${enChiffres(arret)}`)), `${nom} : le compte d’arrêt ${arret}`); + } + }); + + test('les captures reportées ou à venir ne sont pas citées : sans image, elles seraient orphelines', () => { + const citees = new Set(imagesReferencees(lire(GUIDE_USAGE))); + const absentes = [...REPORTEES.map((r) => r.fichier), ...A_VENIR].map(cheminImage); + assert.ok(absentes.length > 0); + assert.deepEqual(absentes.filter((chemin) => citees.has(chemin)), []); + // Chaque capture déclarée qui n'est pas à venir est publiée, donc citée. + const publiees = CAPTURES.filter((c) => !A_VENIR.includes(c.fichier)).map((c) => cheminImage(c.fichier)); + assert.deepEqual(publiees.filter((chemin) => !citees.has(chemin)), []); + }); + + test('rouge : une image retirée du guide devient orpheline, au chemin de cette image', () => { + const texte = lire(GUIDE_USAGE); + const retiree = cheminImage('05-tables-etat'); + assert.ok(texte.includes(retiree)); + const sans = texte.split('\n').filter((ligne) => !ligne.includes(retiree)).join('\n'); + const fautes = verifierAppariement({ manifeste: lireManifeste(), references: referencesDe(sans), images: imagesSurDisque() }); + assert.deepEqual(fautes.map((f) => f.chemin), [retiree]); + assert.match(fautes[0].faute, /^orpheline : .*absente de la documentation$/); + }); + + test('rouge : une section d’étape déplacée ou une image citée sous une autre étape se relèvent', () => { + const texte = lire(GUIDE_USAGE); + const manifeste = lireManifeste(); + const renommee = texte.replace('## Étape 1b \u{2014}', '## Étape 1c \u{2014}'); + assert.notEqual(renommee, texte); + const [etapes, image] = fautesEtapes(renommee, manifeste); + assert.match(etapes, /^sections d'étape \["1","1c","2",/); + assert.equal(image, `${cheminImage('01b-passer-en-ecriture')} : citée d'abord hors de la section de l'étape 1b (1c)`); + const deplacee = cheminImage('10-diagnostic'); + const avancee = texte.replace('## Étape 1b \u{2014}', `![](${deplacee})\n\n## Étape 1b \u{2014}`); + assert.deepEqual(fautesEtapes(avancee, manifeste), [`${deplacee} : citée d'abord hors de la section de l'étape 8 (1)`]); + }); +}); + +// --- Les régions engendrées --------------------------------------------------------- + +describe('documentation : les régions engendrées des documents livrés (§ 19.8)', () => { + test('PORTEURS nomme README.md et le guide d’usage ; chaque porteur porte les régions qui lui reviennent', () => { + assert.deepEqual([...PORTEURS], [README, GUIDE_USAGE]); + assert.deepEqual(lireRegions(lire(README)).map((r) => r.nom), ['contrastes', 'constantes']); + assert.deepEqual( + lireRegions(lire(GUIDE_USAGE)).map((r) => r.nom), + ['reglages', 'colonnes-csv', 'commandes', 'raccourcis', 'demonstrations'], + ); + // Chaque producteur sert une région, et une seule. + const servies = PORTEURS.flatMap((chemin) => lireRegions(lire(chemin)).map((r) => r.nom)).sort(); + assert.deepEqual(servies, PRODUCTEURS.map(({ nom }) => nom).sort()); + }); + + test('verifier, sur l’arbre réel et la liste par défaut : aucune faute, toutes les régions lues', () => { + const bilan = verifierArbre(); + assert.deepEqual(bilan.fautes, []); + assert.equal(bilan.regions, PRODUCTEURS.length); + assert.equal(bilan.code, 0); + }); + + test('aucun document de la racine hors de PORTEURS ne porte de région : rien n’échappe à la vérification', () => { + const documents = readdirSync(RACINE).filter((nom) => nom.endsWith('.md')); + assert.ok(documents.includes(GUIDE_WINDOWS)); + const horsPorteurs = documents.filter((nom) => !PORTEURS.includes(nom)); + assert.deepEqual(horsPorteurs.filter((nom) => lireRegions(lire(nom)).length > 0), []); + }); + + test('la construction de livraison vérifie les régions et la version des captures avant de construire', () => { + const { scripts } = JSON.parse(lire('package.json')); + const etapes = scripts['build:livraison'].split('&&').map((etape) => etape.trim()); + const verifier = etapes.indexOf('node scripts/documentation.js verifier --livraison'); + const construire = etapes.findIndex((etape) => etape.startsWith('vite build')); + assert.ok(verifier >= 0, scripts['build:livraison']); + assert.ok(construire > verifier, scripts['build:livraison']); + }); + + test('rouge : une cellule corrigée à la main dans une région du guide fait échouer verifier', () => { + const texte = lire(GUIDE_USAGE); + const region = lireRegions(texte).find((r) => r.nom === 'reglages'); + const ligne = region.contenu.split('\n').find((l) => l.startsWith('| ') && !l.startsWith('| Réglage')); + const corrige = texte.slice(0, region.debut) + region.contenu.replace(ligne, `${ligne} corrigé |`) + texte.slice(region.fin); + const bilan = verifierArbre({ porteurs: [GUIDE_USAGE], lire: (chemin) => (chemin === GUIDE_USAGE ? corrige : lire(chemin)) }); + assert.deepEqual(bilan.fautes.map((f) => f.chemin), [GUIDE_USAGE]); + assert.match(bilan.fautes[0].faute, /région « reglages » : diffère de ce que le producteur engendre/); + }); +}); + +// --- La section sur l'algorithme (§ 19.9) --------------------------------------------- + +// Les nombres de un à douze, en lettres : la section écrit en lettres tout +// nombre qu'elle ne fait pas recalculer. +const EN_LETTRES = ['zéro', 'un', 'deux', 'trois', 'quatre', 'cinq', 'six', 'sept', 'huit', 'neuf', 'dix', 'onze', 'douze']; +function enLettres(n) { + if (!Number.isInteger(n) || n < 0 || n >= EN_LETTRES.length) throw new RangeError(`pas de lettres pour ${n}`); + return EN_LETTRES[n]; +} + +const EXPOSANTS = new Map([ + ['\u{2070}', 0n], ['\u{B9}', 1n], ['\u{B2}', 2n], ['\u{B3}', 3n], ['\u{2074}', 4n], + ['\u{2075}', 5n], ['\u{2076}', 6n], ['\u{2077}', 7n], ['\u{2078}', 8n], ['\u{2079}', 9n], +]); +// Un nombre écrit en chiffres : des groupes de trois après une espace fine +// insécable, et un exposant facultatif en chiffres supérieurs. +const NOMBRE = /\d+(?:\u{202F}\d{3})*([\u{2070}\u{B9}\u{B2}\u{B3}\u{2074}-\u{2079}]*)/gu; + +// Une puissance de dix écrite comme la section l'écrit : 10 et l'exposant en +// chiffres supérieurs. +const CHIFFRES_SUPERIEURS = [...EXPOSANTS.keys()]; +const puissanceDeDix = (exposant) => `10${[...String(exposant)].map((chiffre) => CHIFFRES_SUPERIEURS[Number(chiffre)]).join('')}`; + +// La valeur d'un nombre écrit, en BigInt. +function valeurEcrite(ecrit, exposant) { + const base = BigInt(ecrit.slice(0, ecrit.length - exposant.length).replaceAll('\u{202F}', '')); + if (exposant === '') return base; + let puissance = 0n; + for (const chiffre of exposant) puissance = puissance * 10n + EXPOSANTS.get(chiffre); + return base ** puissance; +} + +// Le texte lisible d'une section : les cibles des liens et des images +// retirées, qui portent des chemins et non des nombres cités. +const sansCibles = (texte) => texte.replace(/\]\([^)]*\)/g, ']'); + +// Les valeurs des nombres écrits en chiffres, sans doublon, en ordre croissant. +function nombresCites(texte) { + const valeurs = new Set(); + for (const [ecrit, exposant] of sansCibles(texte).matchAll(NOMBRE)) valeurs.add(valeurEcrite(ecrit, exposant)); + return [...valeurs].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)).map(String); +} + +const factorielle = (n) => (n <= 1n ? 1n : n * factorielle(n - 1n)); + +// La configuration d'une démonstration livrée, lue de son fichier. +function configurationLivree(cle) { + const livree = LIVREES.find((l) => l.cle === cle); + assert.ok(livree, `démonstration livrée ${cle}`); + return configurationDepuisCharge(analyser(livree.texte).charge); +} + +/** + * Les nombres de la section recalculés sur les fichiers livrés, et les + * phrases qui les affirment : chaque nombre cité en chiffres y est attaché à + * la grandeur qu'il mesure et aux nombres en lettres dont il découle, de sorte + * qu'un nombre déplacé vers une autre grandeur manque sa phrase même quand + * l'ensemble des nombres cités ne change pas. + * - les façons d'asseoir la petite démonstration pour un tour, tables + * numérotées : n! divisé par le produit des factorielles des capacités ; + * - la plus petite puissance de dix que ce nombre dépasse sur tous les tours ; + * - le plafond a priori de la petite, tours × (capacité − 1) ; + * - le plafond d'un mobile de la grande, sur les tables de la plus grande capacité ; + * - le plafond des animateurs ancrés aux tables de la plus petite capacité. + */ +function attendusAlgorithme() { + const petite = configurationLivree('petite'); + const n = petite.participants.length; + const capacites = petite.tables.map((table) => table.capacite); + assert.equal(capacites.reduce((a, b) => a + b, 0), n, 'la petite démonstration remplit exactement ses tables'); + assert.equal(new Set(capacites).size, 1, 'la petite démonstration a des tables égales'); + const capacite = capacites[0]; + const facons = capacites.reduce((reste, c) => reste / factorielle(BigInt(c)), factorielle(BigInt(n))); + const surTousLesTours = facons ** BigInt(petite.tours); + const puissance = BigInt(String(surTousLesTours).length - 1); + const plafondPetite = diagnostiquer(petite).plafondAPriori; + assert.equal(plafondPetite.min, plafondPetite.max); + assert.equal(plafondPetite.max, petite.tours * (capacite - 1)); + + const grande = configurationLivree('grande'); + const parCapacite = new Map(); + for (const table of grande.tables) parCapacite.set(table.capacite, [...(parCapacite.get(table.capacite) ?? []), table.id]); + const grandeCapacite = Math.max(...parCapacite.keys()); + const petiteCapacite = Math.min(...parCapacite.keys()); + const diagnosticGrande = diagnostiquer(grande); + assert.equal(diagnosticGrande.plafondAPriori.max, grande.tours * (grandeCapacite - 1)); + const tablesAnimees = new Set(parCapacite.get(petiteCapacite)); + const animateurs = grande.reservations + .filter((r) => r.portee === 'tous' && tablesAnimees.has(r.table)) + .map((r) => r.participant); + assert.equal(animateurs.length, tablesAnimees.size, 'un animateur par table de la plus petite capacité'); + const rangDe = new Map(grande.participants.map((p, rang) => [p.id, rang])); + const plafondsAnimateurs = new Set(animateurs.map((id) => diagnosticGrande.plafondAPriori.parPersonne[rangDe.get(id)])); + assert.equal(plafondsAnimateurs.size, 1); + const plafondAnimateurs = [...plafondsAnimateurs][0]; + assert.equal(plafondAnimateurs, grande.tours * (petiteCapacite - 1)); + assert.equal(plafondAnimateurs, diagnosticGrande.plafondAPriori.min); + + const nombres = [facons, 10n ** puissance, BigInt(plafondPetite.max), BigInt(diagnosticGrande.plafondAPriori.max), BigInt(plafondAnimateurs)]; + const phrases = [ + `les ${enLettres(n)} personnes de la petite démonstration et ses ${enLettres(capacites.length)} tables numérotées de ${enLettres(capacite)} places`, + `Pour un seul tour, il y a ${enChiffres(facons)} façons de les répartir`, + `Sur ${enLettres(petite.tours)} tours, le nombre dépasse ${puissanceDeDix(puissance)}`, + `chacun a ${enLettres(capacite - 1)} voisins par tour : en ${enLettres(petite.tours)} tours, il rencontre au plus ${enChiffres(plafondPetite.max)} personnes, sur ${enLettres(n - 1)} personnes`, + `À une table de ${enLettres(grandeCapacite)}, chacun a ${enLettres(grandeCapacite - 1)} voisins : en ${enLettres(grande.tours)} tours, ${enChiffres(diagnosticGrande.plafondAPriori.max)} rencontres au plus`, + `les ${enLettres(animateurs.length)} animateurs des tables de ${enLettres(petiteCapacite)} restent à leur table toute la soirée, et leur plafond vaut ${enChiffres(plafondAnimateurs)}`, + ]; + return { + nombres: [...new Set(nombres)].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)).map(String), + phrases, + depasse: surTousLesTours > 10n ** puissance, + }; +} + +// Les identifiants qu'exporte un module de src/, lus dans son texte. +function exportsDe(texte) { + const noms = new Set(); + for (const [, nom] of texte.matchAll(/export\s+(?:default\s+)?(?:async\s+)?(?:function\*?|class|const|let|var)\s+([A-Za-z_$][\w$]*)/g)) noms.add(nom); + for (const [, liste] of texte.matchAll(/export\s*\{([^}]*)\}/g)) { + for (const partie of liste.split(',')) { + const nom = partie.trim().split(/\s+as\s+/).at(-1); + if (/^[A-Za-z_$][\w$]*$/.test(nom) && nom !== 'default') noms.add(nom); + } + } + return noms; +} + +// Les modules de src/, hors épreuves, en chemins relatifs à la racine. +function modulesSource(dossier = join(RACINE, 'src')) { + return readdirSync(dossier, { withFileTypes: true }).flatMap((entree) => { + const chemin = join(dossier, entree.name); + if (entree.isDirectory()) return modulesSource(chemin); + return entree.name.endsWith('.js') && !entree.name.endsWith('.test.js') ? [relative(RACINE, chemin)] : []; + }); +} + +// Vrai pour un identifiant que sa forme désigne comme du code : une capitale +// après une minuscule, un souligné, un chiffre, ou tout en capitales. Un +// identifiant d'un seul mot minuscule (un verbe, un nom) se lit aussi comme +// un mot de la langue : il n'est relevé qu'appelé, suivi d'une parenthèse. +const formeDeCode = (nom) => /[a-z][A-Z]/.test(nom) || /[_\d$]/.test(nom) || /^[A-Z]{2,}$/.test(nom); + +// Les familles d'algorithmes et les marques de complexité que la section ne +// nomme pas (§ 19.9), cherchées sans égard à la casse. +const VOCABULAIRE_SAVANT = [ + /heuristique/i, /recuit/i, /tabou/i, /glouton/i, /génétique/i, /recherche locale/i, /local search/i, + /hill.?climbing/i, /annealing/i, /\bO\(/, /complexité/i, /NP-/, +]; + +/** + * Les fautes de la section sur l'algorithme : absente ; pas neuf points ; + * un nombre écrit en chiffres que l'arithmétique ne recalcule pas, ou un + * nombre recalculé qu'elle ne cite pas ; une phrase qui affirme un nombre + * absente ; un + * bloc de code, un passage en code, un nom de fichier .js ou un chemin de + * src/ ; un identifiant exporté de src/ ; un mot savant ; ni le diagnostic + * ni les propositions en image. + */ +function fautesAlgorithme(texteGuide, { attendus, exportes }) { + const section = sectionNommee(texteGuide, TITRE_ALGORITHME); + if (section === null) return [`section « ${TITRE_ALGORITHME} » absente`]; + const texte = section.texte; + const fautes = []; + const points = [...texte.matchAll(/^### /gm)].length; + if (points !== 9) fautes.push(`${points} points, le § 19.9 en compte neuf`); + const cites = nombresCites(texte); + if (JSON.stringify(cites) !== JSON.stringify(attendus.nombres)) { + fautes.push(`nombres cités ${cites.join(', ')}, recalculés ${attendus.nombres.join(', ')} ; tout autre nombre s'écrit en lettres`); + } + for (const phrase of attendus.phrases) if (!enLigne(texte).includes(enLigne(phrase))) fautes.push(`phrase absente : « ${phrase} »`); + if (/^(?: {4}|\t)\S/m.test(texte.replace(/^[-*] .*$|^\d+\. .*$/gm, ''))) fautes.push('bloc de code indenté'); + if (/```|~~~/.test(texte)) fautes.push('bloc de code'); + if (/`/.test(texte)) fautes.push('passage en code'); + if (/\.js\b/.test(texte)) fautes.push('nom de fichier .js'); + if (/\bsrc\//.test(texte)) fautes.push('chemin de src/'); + const lisible = sansCibles(texte); + const releves = new Set(); + for (const [mot] of lisible.matchAll(/[\p{L}\p{N}_$]+/gu)) if (exportes.has(mot) && formeDeCode(mot)) releves.add(mot); + for (const [, mot] of lisible.matchAll(/([\p{L}\p{N}_$]+)\(/gu)) if (exportes.has(mot)) releves.add(mot); + for (const mot of [...releves].sort()) fautes.push(`identifiant exporté de src/ : ${mot}`); + for (const motif of VOCABULAIRE_SAVANT) if (motif.test(lisible)) fautes.push(`vocabulaire savant : ${motif}`); + const images = imagesReferencees(texte); + for (const fichier of ['10-diagnostic', '11-propositions']) { + if (!images.includes(cheminImage(fichier))) fautes.push(`la section ne s'appuie pas sur ${fichier}`); + } + return fautes; +} + +describe('documentation : la section sur l’algorithme (§ 19.9)', () => { + const attendus = attendusAlgorithme(); + const exportes = new Set(modulesSource().flatMap((chemin) => [...exportsDe(lire(chemin))])); + + test('les nombres recalculés sur les démonstrations livrées : 369\u{202F}600, 10\u{B2}\u{B2}, 8, 28 et 24, aucun autre', () => { + assert.ok(attendus.depasse); + assert.deepEqual(attendus.nombres, ['8', '24', '28', '369600', String(10n ** 22n)]); + assert.ok(CATALOGUE.some((entree) => entree.cle === 'petite') && CATALOGUE.some((entree) => entree.cle === 'grande')); + }); + + test('la relève des identifiants exportés lit les modules de src/ : elle n’est pas vide', () => { + assert.ok(exportes.size > 100, `${exportes.size} identifiants`); + for (const nom of ['diagnostiquer', 'configurationDepuisCharge', 'CATALOGUE']) assert.ok(exportes.has(nom), nom); + }); + + test('la section du guide : neuf points, les nombres recalculés, ni code, ni identifiant, ni module, ni mot savant', () => { + assert.deepEqual(fautesAlgorithme(lire(GUIDE_USAGE), { attendus, exportes }), []); + }); + + test('rouge : un nombre changé, un nombre de plus en chiffres, une phrase en lettres changée', () => { + const texte = lire(GUIDE_USAGE); + const change = texte.replace('369\u{202F}600', '369\u{202F}601'); + assert.notEqual(change, texte); + assert.match(fautesAlgorithme(change, { attendus, exportes }).join('\n'), /nombres cités 8, 24, 28, 369601,/); + const section = sectionNommee(texte, TITRE_ALGORITHME); + const finSection = section.debut + section.texte.length; + const enPlus = `${texte.slice(0, finSection)}Trois tours suffisent à 12 personnes.\n\n${texte.slice(finSection)}`; + assert.match(fautesAlgorithme(enPlus, { attendus, exportes }).join('\n'), /nombres cités 8, 12, 24,/); + const lettres = texte.replace('sept voisins', 'six voisins'); + assert.notEqual(lettres, texte); + assert.match(fautesAlgorithme(lettres, { attendus, exportes }).join('\n'), /phrase absente : « À une table de huit, chacun a sept voisins/); + }); + + test('rouge : un nombre déplacé vers une autre grandeur, ou un nombre de tours changé à côté d’un nombre cité', () => { + const texte = lire(GUIDE_USAGE); + const avec = (change) => especes(fautesAlgorithme(change, { attendus, exportes })); + // 8 et 24 échangés : l'ensemble des nombres cités ne change pas. + const echange = remplacerPhrase(remplacerPhrase(texte, 'au plus 8 personnes', 'au plus 24 personnes'), 'leur plafond vaut 24', 'leur plafond vaut 8'); + assert.deepEqual(avec(echange), ['phrase absente', 'phrase absente']); + assert.deepEqual(avec(remplacerPhrase(texte, 'Sur quatre tours, le nombre dépasse', 'Sur trois tours, le nombre dépasse')), ['phrase absente']); + assert.deepEqual(avec(remplacerPhrase(texte, 'en quatre tours, 28 rencontres', 'en trois tours, 28 rencontres')), ['phrase absente']); + }); + + test('rouge : un nom de fonction collé, appelé ou en code, un .js, un bloc de code, un mot savant se relèvent', () => { + const texte = lire(GUIDE_USAGE); + const section = sectionNommee(texte, TITRE_ALGORITHME); + const finSection = section.debut + section.texte.length; + const avec = (ajout) => fautesAlgorithme(`${texte.slice(0, finSection)}${ajout}\n\n${texte.slice(finSection)}`, { attendus, exportes }); + assert.deepEqual(avec('Le logiciel appelle configurationDepuisCharge sur le fichier.'), ['identifiant exporté de src/ : configurationDepuisCharge']); + assert.deepEqual(avec('Le moteur lance diagnostiquer(configuration) avant tout.'), ['identifiant exporté de src/ : diagnostiquer']); + assert.deepEqual(avec('Le diagnostic vit dans le fichier diagnostic.js.'), ['nom de fichier .js']); + assert.deepEqual(avec('```\nfaire tant que mieux\n```'), ['bloc de code', 'passage en code']); + assert.deepEqual(avec('C’est une heuristique.'), ['vocabulaire savant : /heuristique/i']); + // Un mot de la langue qu'un module exporte aussi ne se relève pas en prose. + assert.deepEqual(avec('Le logiciel sait diagnostiquer une configuration.'), []); + }); +}); + +// --- GUIDE-WINDOWS.md ------------------------------------------------------------------ + +// Le gabarit du nom de l'exécutable et son préfixe, tirés de la dérivation de +// la version (§ 18.2) : le nom de la construction courante, sa version +// remplacée par les quatre champs nommés. +function gabaritDuNom() { + const { nomFichier } = deriver(VERSION.affichee); + const champs = VERSION.affichee.replaceAll('.', '_'); + assert.ok(nomFichier.includes(champs)); + return { + gabarit: nomFichier.replace(champs, '___'), + prefixe: nomFichier.slice(0, nomFichier.indexOf(champs)), + }; +} + +const NOM_PRODUIT = JSON.parse(lire('package.json')).productName; + +// Les éléments de la première liste numérotée d'un texte, blancs normalisés : +// un élément s'ouvre sur « . » en début de ligne et se poursuit sur +// les lignes indentées qui le suivent ; la première ligne qui n'est ni l'un +// ni l'autre, blanche comprise, ferme la liste. +function listeNumerotee(texte) { + const elements = []; + for (const ligne of texte.split('\n')) { + if (/^\d+\. /.test(ligne)) elements.push(ligne.replace(/^\d+\. /, '')); + else if (elements.length > 0 && /^\s+\S/.test(ligne)) elements[elements.length - 1] += ` ${ligne}`; + else if (elements.length > 0) break; + } + return elements.map(enLigne); +} + +/** + * Les fautes de GUIDE-WINDOWS.md (§ 16.1, § 8.6) : le gabarit et la phrase du + * préfixe absents ; un nom daté cité ; une image ou une région ; un chemin + * absolu ; la règle du dossier de travail sans sa liste de deux issues, dans + * l'ordre du § 8.6 — d'abord le dossier data à côté de l'exécutable, puis le + * dossier du produit dans les Documents —, ni la commande qui l'ouvre ; ni + * « À propos » ni son bloc à copier. + */ +function fautesWindows(brut) { + const { gabarit, prefixe } = gabaritDuNom(); + const texte = enLigne(brut); + const fautes = []; + if (!texte.includes(`\`${gabarit}\``)) fautes.push('gabarit du nom absent'); + if (!texte.includes(`le fichier dont le nom commence par \`${prefixe}\``)) fautes.push('phrase du préfixe absente'); + if (new RegExp(`${prefixe}\\d`).test(texte)) fautes.push('nom daté cité'); + if (/!\[|doc\/captures\/| 0) fautes.push('région engendrée'); + if (cheminsHorsRacine(brut, 'C:\\Racine\\Inventee').length > 0) fautes.push('chemin absolu'); + const dossier = sectionNommee(brut, 'Le dossier de travail'); + if (dossier === null) fautes.push('section « Le dossier de travail » absente'); + else { + const issues = listeNumerotee(dossier.texte); + const [portable = '', documents = ''] = issues; + if (issues.length !== 2) fautes.push(`liste des issues : ${issues.length} élément(s), le § 8.6 en compte deux`); + if (!portable.includes('`data`') || !portable.includes('à côté de l\u{2019}exécutable')) { + fautes.push('issue portable absente : le dossier data à côté de l’exécutable'); + } + if (!documents.includes(`\`Documents\\${NOM_PRODUIT}\``)) fautes.push('issue Documents absente'); + if (!enLigne(dossier.texte).includes(`« ${t('accueil.dossier.explorateur')} »`)) fautes.push('commande qui ouvre le dossier absente'); + } + const version = sectionNommee(brut, 'Où lire la version'); + const texteVersion = version === null ? '' : enLigne(version.texte); + if (!texteVersion.includes(`« ${t('coquille.apropos.titre')} »`) || !texteVersion.includes(`« ${t('coquille.apropos.copier')} »`)) { + fautes.push('« À propos » et son bloc à copier absents'); + } + return fautes; +} + +describe('documentation : GUIDE-WINDOWS.md, texte seul (§ 16.1, § 8.6, § 18.2)', () => { + test('le gabarit, le préfixe, les deux issues du dossier de travail, la commande, la version', () => { + assert.deepEqual(fautesWindows(lire(GUIDE_WINDOWS)), []); + }); + + test('les sections que § 16.1 demande, dans son ordre', () => { + const titres = sections(lire(GUIDE_WINDOWS)).map((s) => s.titre); + assert.deepEqual(titres, [ + 'Obtenir l’exécutable', + 'Le lancer', + 'Le dossier de travail', + 'Sauvegarder', + 'Si l’antivirus bloque', + 'Où lire la version', + 'Signaler un problème', + ]); + }); + + test('rouge : un nom daté, une issue retirée, une image ajoutée se relèvent', () => { + const texte = lire(GUIDE_WINDOWS); + const { prefixe } = gabaritDuNom(); + assert.deepEqual(fautesWindows(`${texte}\nLancer ${prefixe}2026_01_02_03.exe.\n`), ['nom daté cité']); + assert.deepEqual(fautesWindows(texte.replaceAll('`data`', '`donnees`')), ['issue portable absente : le dossier data à côté de l’exécutable']); + assert.deepEqual(fautesWindows(`${texte}\n![](doc/captures/01-premier-ecran.png)\n`), ['image']); + }); + + test('rouge : l’issue portable retirée de la liste, la seconde renumérotée, se relève', () => { + const lignes = lire(GUIDE_WINDOWS).split('\n'); + const premier = lignes.findIndex((ligne) => /^1\. /.test(ligne)); + const second = lignes.findIndex((ligne) => /^2\. /.test(ligne)); + assert.ok(premier >= 0 && second > premier); + const sans = [...lignes.slice(0, premier), lignes[second].replace(/^2\. /, '1. '), ...lignes.slice(second + 1)].join('\n'); + assert.deepEqual(fautesWindows(sans), [ + 'liste des issues : 1 élément(s), le § 8.6 en compte deux', + 'issue portable absente : le dossier data à côté de l’exécutable', + 'issue Documents absente', + ]); + }); +}); diff --git a/vitest.config.js b/vitest.config.js index 5a71d78..ea7cb07 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -137,7 +137,9 @@ export default defineConfig({ // projet, elle est ignorée sans avertissement. watchTriggerPatterns: [ { pattern: /(^|\/)(version\.json|package-lock\.json|CHANGELOG\.md|README\.md|GUIDE-[^/]*\.md)$/, - testsToRun: () => 'scripts/version.test.js' }, + testsToRun: () => ['scripts/version.test.js', 'test/documentation.test.js'] }, + { pattern: /(^|\/)(package\.json|doc\/captures\/[^/]+|src\/interface\/parcours\.navigateur\.test\.js)$/, + testsToRun: () => 'test/documentation.test.js' }, { pattern: /(^|\/)electron\/[^/]+$/, testsToRun: () => ['test/coquille.test.js', 'test/fichiers_electron.test.js', 'test/livrable.test.js', 'scripts/essai_demarrage.test.js', 'scripts/version.test.js'] },