[ADD] docs: usage and Windows guides, with how the search works

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
This commit is contained in:
Mathieu Benoit 2026-10-09 17:14:56 -04:00
parent 1e5039af5b
commit 9994858904
7 changed files with 1495 additions and 4 deletions

604
GUIDE-USAGE.md Normal file
View file

@ -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).
<!-- engendre:debut reglages -->
| 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 |
<!-- engendre:fin reglages -->
## 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.
<!-- engendre:debut colonnes-csv -->
| Colonne | Synonymes reconnus |
|---|---|
| `nom` | — |
| `prenom` | — |
| `appartenance` | `organisation`, `entreprise`, `équipe` |
| `courriel` | — |
| `titre_pressenti` | `titre`, `rôle` |
| `exclu` | — |
| `notes` | — |
<!-- engendre:fin colonnes-csv -->
## 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.
<!-- engendre:debut commandes -->
| 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 |
<!-- engendre:fin commandes -->
## Annexe D — Les raccourcis clavier
<!-- engendre:debut raccourcis -->
| 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. |
<!-- engendre:fin raccourcis -->
## 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.
<!-- engendre:debut demonstrations -->
| 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 |
<!-- engendre:fin demonstrations -->

181
GUIDE-WINDOWS.md Normal file
View file

@ -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<AAAA>_<MM>_<JJ>_<NN>.exe` : `<AAAA>`, `<MM>` et
`<JJ>` sont l’année, le mois et le jour de la version, `<NN>` 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 : `<nom>.gtt.json`, son état complet, et
`<nom>.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, `<nom>.gtt.json` et
`<nom>.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.

View file

@ -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<AAAA>_<MM>_<JJ>_<NN>.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.
<!-- engendre:debut contrastes -->
| 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 | — | — |
<!-- engendre:fin contrastes -->
## 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é.
<!-- engendre:debut constantes -->
| 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 |
<!-- engendre:fin constantes -->
## Licence
© 2026 TechnoLibre. GNU Affero General Public License, version 3 ou ultérieure ;

View file

@ -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",

View file

@ -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<string>}
*/
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 {

618
test/documentation.test.js Normal file
View file

@ -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 <id> — <intitulé> ».
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, '<AAAA>_<MM>_<JJ>_<NN>'),
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 « <chiffres>. » 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\/|<img/i.test(texte)) fautes.push('image');
if (lireRegions(brut).length > 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',
]);
});
});

View file

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