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'] },