Compare commits

...

20 commits

Author SHA1 Message Date
de46c582f4 [IMP] tests: watched series adapts to the core count, threads below 4
Below four cores the runner carried the watched series in one process,
and a relaunch after a base module took 2.5 to 3 s, over the 2 s budget
of § 14.14. The configuration now reads the cores the process may use:
from four, full processes and the whole series, as before; below, threads
on every core, and the end-to-end test moves to the long series. No test
disappears or changes behaviour. A guard refuses, in these series, what a
thread refuses and a process accepts; the reporter names the mode.

Checked: relaunch on 2 cores 1.0 to 1.3 s, was 2.5 to 3.0; on 4 cores
1.2 to 1.5 s; process.env is copied per thread, measured.

--- FR ---

[IMP] épreuves : la série surveillée suit les cœurs, threads sous 4

Sous quatre cœurs, le lanceur portait la série surveillée dans un seul
processus, et une relance après un module de base prenait 2,5 à 3 s,
au-delà du budget de 2 s du § 14.14. La configuration lit les cœurs que
le processus peut occuper : à partir de quatre, processus complets et
série entière, comme avant ; en deçà, threads sur tous les cœurs, et
l'épreuve de bout en bout passe dans la série longue. Aucune épreuve ne
disparaît ni ne change. Une garde refuse dans ces séries ce qu'un thread
refuse et qu'un processus accepte ; le rapporteur nomme le mode.

Vérifié : relance sur 2 cœurs en 1,0 à 1,3 s, contre 2,5 à 3,0 ; sur 4
cœurs en 1,2 à 1,5 s ; process.env est copié par thread, mesuré.

Assisted-by: Claude Opus 5.5
2026-10-06 04:54:01 -04:00
1910e207cb [FIX] tests: four-core budget, quoted values, key guard, switch floor
The relaunch budget of § 14.14 was out of reach on two cores whatever the
tests did; the spec now names its reference machine, four cores, and the
watched series drops a 200 000-move search the long series already runs.
The itinerary guard quotes a string value as the indexed-plan guard does,
through one shared helper. The key-order guard no longer mistakes the
JSDoc type {Object[]} for a computed access, and the property grid counts
the proposals that switch to the constraint order, under a floor.

Checked: 456 node tests in 1.8 s; each new test failed first; cutting the
descent to one move drops the switch count from 86 to 30, under its floor.

--- FR ---

[FIX] épreuves : budget sur quatre cœurs, citation, clés, bascule

Le budget de relance du § 14.14 était hors d'atteinte sur deux cœurs,
quoi que fassent les épreuves ; le spec nomme désormais sa machine de
référence, quatre cœurs, et la série surveillée quitte une recherche de
200 000 mouvements que la série longue joue déjà. La garde d'itinéraire
cite une chaîne comme la garde du plan indexé, par un seul assistant. La
garde de l'ordre des clés ne prend plus le type JSDoc {Object[]} pour un
accès calculé, et la grille compte, sous un plancher, les propositions
qui basculent dans l'ordre des contraintes.

Vérifié : 456 épreuves node en 1,8 s ; chaque épreuve nouvelle a échoué
d'abord ; une descente coupée au premier mouvement fait tomber les
bascules de 86 à 30, sous leur plancher.

Assisted-by: Claude Opus 5.5
2026-10-06 04:26:08 -04:00
4795114093 [ADD] tests: budget reporter, one import parser, key-order guard
The time budget of § 14.14 had no enforcement: a reporter now prints the
total and the ten slowest tests, and fails a run whose node project
exceeds 30 s or node-long 60 s. Two guards parsed imports with two
patterns, one blind past an apostrophe in a comment; both now use one
parser anchored on « from ». The determinism guard refuses key-order
walks, and one test binds the application name across its three
declarations. The end-to-end generations drop to 100 000 moves.

Checked: each ceiling, the apostrophe case and a key walk are shown
failing before the guard that catches them.

--- FR ---

[ADD] épreuves : rapporteur de budget, imports, ordre des clés

Le budget de temps du § 14.14 n'avait aucune garde : un rapporteur
imprime désormais la durée totale et les dix épreuves les plus lentes,
et fait échouer une exécution dont le projet node dépasse 30 s ou
node-long 60 s. Deux gardes lisaient les imports par deux motifs, l'un
aveugle après une apostrophe en commentaire ; les deux passent par un
seul analyseur ancré sur « from ». La garde de déterminisme refuse les
parcours de clés, et une épreuve lie le nom de l'application à ses
trois déclarations. Les générations de bout en bout passent à 100 000.

Vérifié : chaque plafond, le cas de l'apostrophe et un parcours de clés
sont montrés en échec avant la garde qui les attrape.

Assisted-by: Claude Opus 5.5
2026-10-06 04:23:13 -04:00
6c67cba949 [IMP] engine: single owners, regeneration, property grid
The search recomputed the collision excess and the gaps that the measure
already gives — two arithmetics for one quantity. It now reads them from
the measure, and the redundancy total has one owner. Contract values, the
indexed-plan guard and the population split are defined once. regenerer
rebuilds a proposition from its derived seed, stop count and history
length, which the spec now lists (§ 8.9). A property grid runs seeded
random rooms through the whole chain.

Checked: 435 node and 20 long tests; redefining the excess or the gaps in
the measure now fails the search tests too.

--- FR ---

[IMP] moteur : propriétaires uniques, régénération, grille de propriétés

La recherche recalculait l'excédent de collisions et les écarts que la
mesure donne déjà — deux arithmétiques pour une grandeur. Elle les lit
désormais dans la mesure, et le total de redondance a un seul
propriétaire. Valeurs du contrat, garde du plan indexé et partition des
populations sont définies une fois. regenerer reconstruit une proposition
depuis sa graine dérivée, son arrêt et la longueur de son historique,
que le spec énumère désormais (§ 8.9). Une grille de propriétés fait
traverser toute la chaîne à des salles tirées d'une graine.

Vérifié : 435 épreuves node et 20 longues ; redéfinir l'excédent ou les
écarts dans la mesure fait désormais tomber aussi la recherche.

Assisted-by: Claude Opus 5.5
2026-10-06 04:23:02 -04:00
c7cd5f9142 [ADD] engine: end-to-end runs on the demonstrations, measured thresholds
The whole chain — search, both verifiers, measure, ceilings, ranking —
runs end to end on the demonstrations. At 500 000 moves the large one
reaches its proven floor: largest gap 1, no collision, no repeat. Its
quality thresholds were set after measurement, with the measurement in
the comment, never guessed.

Checked: small at 8 for all and certified; conflict variant spreads its 4
collisions over 4 pairs; large at gap 1 with 99 imposed returns; the
ranking holds when its order differs from id order.

--- FR ---

[ADD] moteur : la chaîne de bout en bout, seuils posés après mesure

Toute la chaîne — recherche, les deux vérificateurs, mesure, plafonds,
classement — tourne de bout en bout sur les démonstrations. À 500 000
mouvements, la grande atteint son plancher prouvé : écart maximal 1,
aucune collision, aucune répétition. Ses seuils de qualité sont posés
après mesure, la mesure en commentaire, jamais de tête.

Vérifié : petite à 8 pour tous et certifiée ; variante en conflit à 4
collisions sur 4 paires ; grande à l'écart 1 avec 99 retours imposés ; le
classement tient quand son ordre diffère de celui des identifiants.

Assisted-by: Claude Opus 5.5
2026-10-05 22:52:30 -04:00
f8b3e8b0a9 [ADD] engine: lack, its three figures, dominance and the ranking
Raw meeting counts stop comparing proposals once a table is incomplete;
the lack, realised ceiling minus encounters, compares them. The module
gives the three figures of § 12.10.4, the sorted lack profile, rank
counts and profile dominance between two proposals, and the minimum
certificate read against the a priori ceiling. The ranking orders by gap,
then collision excess, cumulative collisions, repeats and redundancy.

Checked: the operator's three plans rank by excess 0, 2, 3, which either
count alone would invert; dominance and switch rank are pinned by hand.

--- FR ---

[ADD] moteur : manque, ses trois chiffres, dominance et classement

Les nombres bruts de rencontres cessent de comparer deux propositions dès
qu'une table est incomplète ; le manque, plafond réalisé moins
rencontres, les compare. Le module rend les trois chiffres du
§ 12.10.4, le profil trié du manque, le décompte des rangs et la
dominance de profil entre deux propositions, et le certificat de minimum
lu contre le plafond a priori. Le classement ordonne par écart, puis
excédent de collisions, collisions cumulées, répétitions et redondance.

Vérifié : les trois plans de l'opérateur se classent par excédent 0, 2,
3, ce que chaque compte seul inverserait ; dominance et rang de bascule
épinglés à la main.

Assisted-by: Claude Opus 5.5
2026-10-05 22:52:29 -04:00
f5d1bb04bd [ADD] engine: diagnosis of what a configuration makes unavoidable
Before any search, the diagnosis states what no plan can avoid: missing
seats, the a priori ceiling, per-group collision floors in cumulative
occurrences, distinct pairs and excess, imposed returns, anchoring per
table, and the floor of the itinerary gap where the room is tight. A
floor it cannot prove is null, never zero, so the operator is never sent
to rerun towards a value the configuration forbids.

Checked: the large demonstration gives a floor of 1 with at least 24
mobiles; collision floors equal exact minima found by enumeration, the
§ 5.6 example of 8 collisions on 2 pairs included.

--- FR ---

[ADD] moteur : diagnostic de ce qu'une configuration rend inévitable

Avant toute recherche, le diagnostic énonce ce qu'aucun plan n'évite :
places manquantes, plafond a priori, planchers de collisions par groupe
en occurrences cumulées, paires distinctes et excédent, retours imposés,
ancrage par table, et le plancher de l'écart d'itinéraire là où la salle
est tendue. Un plancher qu'il ne sait pas prouver vaut null, jamais
zéro : l'opérateur n'est jamais envoyé relancer vers une valeur que la
configuration interdit.

Vérifié : la grande démonstration donne un plancher de 1 pour 24 mobiles
au moins ; les planchers de collisions égalent les minimums exacts
trouvés par énumération, l'exemple du § 5.6 compris.

Assisted-by: Claude Opus 5.5
2026-10-05 22:52:29 -04:00
74c21ee031 [ADD] search: seeded late acceptance over a lexicographic score
A proposition is the best plan a late-acceptance descent finds from its
derived seed. It stops on a count of moves, never on a duration, so one
seed gives one plan on any machine. The score is a six-integer tuple
compared lexicographically: largest gap to the a priori ceiling, sum of
squared gaps, collision excess, cumulative collisions, repeats with chosen
returns, redundancy. An incremental state scores a swap in O(capacity);
the full recomputation, from the measure, checks it.

Checked: the small demonstration reaches 8 met for all twelve with no
special case; incremental and full scores agree up to 10 000 moves.

--- FR ---

[ADD] recherche : acceptation tardive ensemencée, score lexicographique

Une proposition est le meilleur plan que trouve une descente par
acceptation tardive depuis sa graine dérivée. Elle s'arrête sur un compte
de mouvements, jamais sur une durée : une graine donne un plan sur toute
machine. Le score est un n-uplet de six entiers comparé dans l'ordre
lexicographique : plus grand écart au plafond a priori, somme des carrés
des écarts, excédent de collisions, collisions cumulées, répétitions et
retours choisis, redondance. Un état incrémental évalue un échange en
O(capacité) ; le recalcul complet, tiré de la mesure, le vérifie.

Vérifié : la petite démonstration atteint 8 rencontres pour les douze sans
cas particulier ; scores incrémental et complet s'accordent jusqu'à
10 000 mouvements.

Assisted-by: Claude Opus 5.5
2026-10-05 22:52:29 -04:00
a2a3a7eca8 [ADD] demo: four seeded configurations, forged names, witness round
The demonstrations are built from a seed in a fixed draw order. The large
one seats 260 at 29 tables of 8 and 4 of 7 with one facilitator each, its
variant at 33 tables of 8; the small one seats 12 at four tables of 3 in
groups 4/4/4, its conflict variant 5/4/3. Group sizes follow the weighted
law, a seed with no witness round is refused, and forged names appear
nowhere else in the repository.

Checked: the large configuration is tight at 260 seats; each of the four
refusals raises its named error; a wrong table split fails 12 tests.

--- FR ---

[ADD] demo : quatre configurations ensemencées, noms forgés, tour témoin

Les démonstrations se construisent depuis une graine, dans un ordre de
tirages fixé. La grande assoit 260 personnes à 29 tables de 8 et 4 de 7,
un animateur par table, sa variante à 33 tables de 8 ; la petite assoit
12 personnes à quatre tables de 3 en groupes 4/4/4, sa variante de
conflit 5/4/3. Les tailles de groupe suivent la loi pondérée, une graine
sans tour témoin est refusée, et les noms forgés n'apparaissent nulle
part ailleurs dans le dépôt.

Vérifié : la grande configuration est tendue à 260 sièges ; chacun des
quatre refus lève son erreur nommée ; une mauvaise répartition des tables
fait échouer 12 épreuves.

Assisted-by: Claude Opus 5.5
2026-10-05 17:27:34 -04:00
926a3ff21f [ADD] engine: invariant and indicator verifiers, as application code
Both verifiers ship with the application and run on every generation and
every loaded file: the first judges a plan against the instance, the
second the line N − 1 ≥ a priori ≥ realised ≥ met for each person. Neither
throws on a content defect — each defect is a named violation — and
neither modifies what it receives.

Checked: one mutation per violation code yields that violation and no
other; frozen inputs stay intact; three mutants that wrote to them fail.

--- FR ---

[ADD] moteur : vérificateurs d'invariants et d'indicateurs, applicatifs

Les deux vérificateurs sont livrés avec l'application et jouent à chaque
génération et à chaque fichier chargé : le premier juge un plan contre
l'instance, le second la ligne N − 1 ≥ a priori ≥ réalisé ≥ rencontres
pour chacun. Aucun ne lève sur un défaut du contenu — chaque défaut est
une violation nommée — et aucun ne modifie ce qu'il reçoit.

Vérifié : une mutation par code produit cette violation et aucune autre ;
les entrées gelées restent intactes ; trois mutants qui y écrivaient
échouent.

Assisted-by: Claude Opus 5.5
2026-10-05 17:27:34 -04:00
9efcfc9973 [ADD] engine: ceilings a priori and realised, exact dynamic programme
The a priori ceiling is maximised over admissible itineraries by a dynamic
programme over tables, free rounds and capped seats, memoised by
signature, with an exhaustive enumeration as its oracle. The realised
ceiling counts the occupancy of each table at each round, not its
capacity, so a lack of zero means nothing left to take from that plan.

Checked: anchored 16 and mobile 19 on four tables of five; 28, 27, 28 and
24 on the large shape; the programme equals the enumeration on random
instances; five mutants of the formula and the programme each fail.

--- FR ---

[ADD] moteur : plafonds a priori et réalisé, programmation dynamique

Le plafond a priori se maximise sur les itinéraires admissibles par une
programmation dynamique sur les tables, les tours libres et les sièges
plafonnés, mémoïsée par signature, avec une énumération exhaustive pour
oracle. Le plafond réalisé compte l'occupation de chaque table à chaque
tour, non sa capacité : un manque nul veut dire que ce plan n'a plus rien
à reprendre.

Vérifié : ancré 16 et mobile 19 sur quatre tables de cinq ; 28, 27, 28 et
24 sur la forme de la grande ; la programmation égale l'énumération sur
des instances tirées ; cinq mutants de la formule et du calcul échouent.

Assisted-by: Claude Opus 5.5
2026-10-05 17:27:34 -04:00
30045182e7 [ADD] engine: indicators measured by full recomputation
Full recomputation is the measure every displayed figure comes from
(§ 5.10). It keeps the three collision units apart — cumulative
occurrences, distinct pairs, their excess — splits table returns and
repeated meetings into those the engine chose and those the reservations
impose, and gives null, never zero, for an empty population.

Checked: the small demonstration's perfect plan gives 8 met, A = 2,
r = 6, d = 0.25 for all twelve; spread and concentrated plans give
excess 0 and 4.

--- FR ---

[ADD] moteur : indicateurs mesurés par recalcul complet

Le recalcul complet est la mesure dont sort tout chiffre affiché
(§ 5.10). Il tient séparées les trois unités de collision — occurrences
cumulées, paires distinctes, leur excédent —, partage les retours à une
table et les rencontres répétées entre ceux que le moteur a choisis et
ceux qu'imposent les réservations, et rend null, jamais zéro, pour une
population vide.

Vérifié : le plan parfait de la petite démonstration donne 8 rencontres,
A = 2, r = 6, d = 0,25 pour les douze ; plans réparti et concentré
donnent un excédent de 0 et de 4.

Assisted-by: Claude Opus 5.5
2026-10-05 17:27:34 -04:00
4e31c8d3e5 [ADD] guards: determinism and layer boundary checked over src/
A seed reproduces a plan only if nothing in the engine or the demo
generator reads the clock, a random source or the locale. One guard reads
every module of src/moteur and src/demo and names each such call by its
line, Node's crypto draws included. A second walks the engine's import
graph and refuses the interface, storage, Svelte, Capacitor, Electron,
Node built-ins and browser globals. Both fail on an empty tree.

Checked: each forbidden form is caught in a temporary tree; the real tree
is clean.

--- FR ---

[ADD] garde-fous : déterminisme et frontière des couches sur src/

Une graine ne reproduit un plan que si rien, dans le moteur ni dans le
générateur de démonstrations, ne lit l'horloge, une source aléatoire ou la
locale. Une garde lit chaque module de src/moteur et de src/demo et nomme
chaque tel appel par sa ligne, tirages de crypto de Node compris. Une
seconde parcourt le graphe d'imports du moteur et refuse l'interface, le
stockage, Svelte, Capacitor, Electron, les modules natifs de Node et les
globales du navigateur. Les deux échouent sur un arbre vide.

Vérifié : chaque forme interdite est relevée dans un arbre temporaire ;
l'arbre réel est propre.

Assisted-by: Claude Opus 5.5
2026-10-05 17:27:06 -04:00
518ef93f67 [ADD] engine: configuration normalised into an indexed instance
The engine works on dense arrays, not on the operator's objects.
normaliser checks the configuration, derives anchored, partially fixed and
mobile statuses from the reservations themselves, and suspends an excluded
person's reservations without stopping on them. It never refuses a room
that is too small: that is a property of the configuration, which the
diagnosis names and the search refuses (§ 5.9). A plan converts both ways
between ids and indices, seats in ascending id.

Checked: a reservation after an excluded one, and a conflict found under
« tous », each fail their mutant; an independent oracle agreed throughout.

--- FR ---

[ADD] moteur : configuration normalisée en une instance indexée

Le moteur travaille sur des tableaux denses, pas sur les objets de
l'opérateur. normaliser vérifie la configuration, dérive les statuts
ancré, partiellement fixé et mobile des réservations elles-mêmes, et
suspend les réservations d'un exclu sans s'y arrêter. Il ne refuse jamais
une salle trop petite : c'est une propriété de la configuration, que le
diagnostic nomme et que la recherche refuse (§ 5.9). Un plan passe dans
les deux sens des ids aux index, sièges par id croissant.

Vérifié : une réservation après celle d'un exclu, et un conflit découvert
sous « tous », font chacune tomber leur mutant ; un oracle indépendant
concorde partout.

Assisted-by: Claude Opus 5.5
2026-10-05 17:27:06 -04:00
cd9ea442a6 [ADD] prng: PCG32 checked against the reference C implementation
The demonstrations and the search must give the same plan from the same
seed on any machine, so every draw comes from one generator with explicit
state. PCG32 runs on 16-bit limbs without BigInt, seeded as
pcg32_srandom_r; bounded draws reject as pcg32_boundedrand_r does, and the
shuffle loop is the reference demo's. Expected values come from compiling
and running the reference implementation, never from this code.

Checked: outputs, coins, dice and deck match the reference demo; a wrong
multiplier, rotation or remainder each fail.

--- FR ---

[ADD] prng : PCG32 vérifié contre l'implémentation C de référence

Les démonstrations et la recherche doivent rendre le même plan pour la
même graine sur toute machine : chaque tirage vient d'un seul générateur à
état explicite. PCG32 calcule par mots de 16 bits sans BigInt, ensemencé
comme pcg32_srandom_r ; le tirage borné rejette comme
pcg32_boundedrand_r, et la boucle de mélange est celle de la démo de
référence. Les valeurs attendues sortent de l'implémentation de référence
compilée et exécutée, jamais de ce code.

Vérifié : sorties, pièces, dés et paquet égalent la démo de référence ;
un multiplicateur, une rotation ou un reste faussés échouent chacun.

Assisted-by: Claude Opus 5.5
2026-10-05 17:27:06 -04:00
100c0d3399 [FIX] electron: remove Electron's default English application menu
Without a menu set by the application, Electron installs its own: in
English, while the interface is French, and offering Reload and the
developer tools to the operator. The shell now sets a null menu before
its first window, which also removes the menu bar on Windows.

Checked: the shell test fails without the call and passes with it; the
rebuilt exe, run under Wine 11, shows the version banner and no menu bar.

--- FR ---

[FIX] electron : retirer le menu anglais par défaut d'Electron

Sans menu posé par l'application, Electron installe le sien : en
anglais, alors que l'interface est en français, et offrant à l'opérateur
le rechargement et les outils de développement. La coquille pose
désormais un menu nul avant sa première fenêtre, ce qui retire aussi la
barre de menu sous Windows.

Vérifié : l'épreuve de la coquille échoue sans l'appel et passe avec ;
l'exécutable reconstruit, lancé sous Wine 11, montre le bandeau de
version et aucune barre de menu.

Assisted-by: Claude Opus 5.5
2026-10-05 13:47:16 -04:00
a3354e2b0b [ADD] electron: own shell, portable Windows exe built under podman
The deliverable is a single-file portable executable whose Electron the
project updates itself. The shell keeps context isolation on, Node
integration off and the page sandboxed; one IPC channel returns the
folder the portable launcher publishes. The build runs in a no-Wine image
pinned by digest, relays no host variable, refuses to overwrite a built
number, removes what a failed build leaves, and accepts only the name
version.json gives. The spec stops claiming the portable target needs
Wine.

Checked: exe built in 86 s, resource FileVersion 2026.1005.1.0 and
© 2026 TechnoLibre; 109 node tests pass; two end-of-build mutations fail.

--- FR ---

[ADD] electron : coquille propre, exécutable portable construit sous podman

Le livrable est un exécutable portable à fichier unique, dont le projet
met lui-même Electron à jour. La coquille garde l'isolation de contexte,
coupe l'intégration de Node et isole la page ; un seul canal IPC rend le
dossier que publie le lanceur portable. La construction s'exécute dans
une image sans Wine épinglée par empreinte, ne relaie aucune variable de
l'hôte, refuse d'écraser un numéro construit, retire ce qu'un échec
laisse, et n'accepte que le nom que donne version.json. Le spec cesse
d'affirmer que la cible portable exige Wine.

Vérifié : exécutable construit en 86 s, ressource FileVersion
2026.1005.1.0 et © 2026 TechnoLibre ; 109 épreuves node passent ; deux
mutations du contrôle de fin échouent.

Assisted-by: Claude Opus 5.5
2026-10-05 13:13:32 -04:00
9fc104d0e9 [ADD] skeleton: Capacitor web app that shows its dated version
The engine to come must be proven against oracles under node in seconds,
so the skeleton sets three test projects: node, watched; node-long; and a
real Chromium that reads computed styles in both themes. The version
lives in version.json alone; a check refuses eight divergences — technical
form, generated module, changelog head, stray version strings — and a
build guard keeps test files and fixture assets out of the bundle.

Checked: on this commit alone, 73 node tests, 7 browser tests and the
version check pass; the guard fails on a fixture joined as an asset.

--- FR ---

[ADD] squelette : application web Capacitor qui affiche sa version datée

Le moteur à venir doit s'éprouver contre des oracles sous node en
quelques secondes : le squelette pose trois projets d'épreuve — node,
sous surveillance ; node-long ; et un vrai Chromium qui lit le style
calculé dans les deux thèmes. La version vit dans version.json seul ; un
contrôle refuse huit divergences — forme technique, module engendré,
tête du changelog, chaînes de version égarées — et une garde de
construction tient fichiers et données d'épreuve hors du paquet.

Vérifié : sur ce commit seul, 73 épreuves node, 7 navigateur et le
contrôle de version passent ; la garde échoue sur une donnée jointe.

Assisted-by: Claude Opus 5.5
2026-10-05 13:13:13 -04:00
0bd3f08093 [ADD] license: GNU AGPL v3 text, TechnoLibre copyright
The project is licensed under the GNU Affero General Public License,
version 3 or later, copyright TechnoLibre — the convention every
TechnoLibre script and module already follows. The text is the current
canonical one published by the Free Software Foundation, copied byte for
byte.

Checked: sha256 matches the file served by gnu.org.

--- FR ---

[ADD] licence : texte de la GNU AGPL v3, copyright TechnoLibre

Le projet est sous licence publique générale GNU Affero, version 3 ou
ultérieure, copyright TechnoLibre — la convention que suivent déjà tous
les scripts et modules de TechnoLibre. Le texte est le texte canonique
actuel publié par la Free Software Foundation, copié octet pour octet.

Vérifié : l'empreinte sha256 égale celle du fichier servi par gnu.org.

Assisted-by: Claude Opus 5.5
2026-10-05 07:12:53 -04:00
7e1042aacd [UPD] spec: Capacitor replaces Cordova, own Electron shell for Windows
The only route from Cordova to a Windows executable is cordova-electron,
whose latest stable release pins Electron 29, out of support. Capacitor
offers no maintained desktop platform either, so the executable now
ships a project-owned Electron shell packaged by electron-builder, which
sets its own Electron version and the exe name directly. The shell hosts
the file system and printing boundaries behind a narrow preload bridge,
with context isolation on and Node integration off in the page.

Checked: no Cordova mention left; every internal reference resolves.

--- FR ---

[UPD] spec : Capacitor remplace Cordova, coquille Electron pour Windows

Le seul chemin de Cordova vers un exécutable Windows est
cordova-electron, dont la dernière version stable épingle Electron 29,
hors support. Capacitor n'offre pas non plus de plateforme de bureau
maintenue : l'exécutable embarque donc une coquille Electron propre au
projet, emballée par electron-builder, qui fixe elle-même sa version
d'Electron et le nom de l'exécutable. La coquille porte le système de
fichiers et l'impression derrière un pont de préchargement étroit,
isolation de contexte active et intégration de Node coupée.

Vérifié : plus aucune mention de Cordova ; chaque renvoi interne résout.

Assisted-by: Claude Opus 5.5
2026-10-05 06:57:26 -04:00
74 changed files with 24419 additions and 40 deletions

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
node_modules/
www/
dist/
dist-electron/
android/
ios/
.vite/
.vitest/
coverage/

12
CHANGELOG.md Normal file
View file

@ -0,0 +1,12 @@
# Changelog — Gestion table tournante Libre
## 2026.10.05.01 — 5 octobre 2026
**Ce qui change pour vous**
- L'application s'ouvre et affiche sa version dans un bandeau permanent et dans
le titre de sa fenêtre.
**Version de format des fichiers** : aucune — cette version n'enregistre encore
aucun événement.
**En remplaçant** : rien à faire, c'est la première version.

661
LICENSE Normal file
View file

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View file

@ -0,0 +1,26 @@
# Gestion table tournante Libre
Logiciel hors ligne qui place les personnes d'un événement autour de tables et
les fait tourner sur plusieurs tours, pour que chacune rencontre le plus de
monde possible.
## Commandes
| commande | effet |
|---|---|
| `npm install` | installe les outils de construction et d'épreuve |
| `npx playwright install chromium` | télécharge le Chromium des épreuves navigateur, une fois par machine |
| `npm run dev` | sert l'application en développement, sur la plateforme web |
| `npm test` | série `node` : les fonctions pures, à chaque modification |
| `npm run test:long` | épreuves lourdes, avant un commit |
| `npm run test:navigateur` | épreuves de rendu dans Chromium, avant une livraison |
| `npm run build` | construit la page dans `www/` |
| `scripts/construire_windows.sh` | construit l'exécutable Windows portable, sous `podman` |
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`.
## Licence
© 2026 TechnoLibre. GNU Affero General Public License, version 3 ou ultérieure ;
texte complet dans `LICENSE`.

5
capacitor.config.json Normal file
View file

@ -0,0 +1,5 @@
{
"appId": "ca.technolibre.gestiontabletournante",
"appName": "Gestion table tournante Libre",
"webDir": "www"
}

View file

@ -0,0 +1,35 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Configuration d'electron-builder : un exécutable Windows portable à fichier
// unique (§ 16), nommé d'après la version datée (§ 18.2).
//
// electron-builder ne lit ce fichier que par « --config
// electron-builder.config.cjs » : son nom n'est pas de ceux qu'il cherche
// seul, et sans lui il retombe sur ses défauts, dont un installateur. Le
// script scripts/construire_windows.sh le lui passe.
//
// Le nom du livrable sort de deriver, appliqué à version.json : la
// configuration ne le compose pas (§ 13.2). scripts/version.js est un module
// ESM, chargé ici par require : Node l'accepte depuis 20.19 et 22.12, pour un
// module sans await de premier niveau.
const { readFileSync } = require('node:fs');
const { join } = require('node:path');
const { deriver } = require('./scripts/version.js');
const lireJson = (nom) => JSON.parse(readFileSync(join(__dirname, nom), 'utf8'));
module.exports = {
// Un seul identifiant pour toutes les plateformes, celui de Capacitor.
appId: lireJson('capacitor.config.json').appId,
productName: lireJson('package.json').productName,
// Champ LegalCopyright de la ressource de version de l'exécutable.
copyright: '© 2026 TechnoLibre',
directories: { output: 'dist-electron' },
// La page construite, la coquille, et package.json, dont electron-builder
// tire le point d'entrée. package.json ne déclarant aucune dépendance
// d'exécution, aucun node_modules n'entre dans le paquet.
files: ['www/**', 'electron/**', 'package.json'],
artifactName: deriver(lireJson('version.json').version).nomFichier,
win: { target: [{ target: 'portable', arch: ['x64'] }] },
};

56
electron/main.js Normal file
View file

@ -0,0 +1,56 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Processus principal de la coquille Electron (§ 13.1) : une fenêtre qui
// charge la page construite par Vite dans www/, et un seul canal IPC, que
// electron/preload.cjs expose à la page sous window.gtt.
//
// La page n'a ni Node ni accès au disque : isolation de contexte active,
// intégration de Node coupée, rendu en bac à sable. Elle n'atteint le
// processus principal que par les canaux que ce module traite. Un nom importé
// interprété comme du balisage reste ainsi une faute d'affichage, et non un
// accès au disque.
import { app, BrowserWindow, ipcMain, Menu } from 'electron';
import { fileURLToPath } from 'node:url';
// Chemins relatifs à ce module : ils valent dans le dépôt comme dans
// l'archive app.asar de l'exécutable, qui reproduit electron/ et www/.
const PRECHARGEMENT = fileURLToPath(new URL('./preload.cjs', import.meta.url));
const PAGE = fileURLToPath(new URL('../www/index.html', import.meta.url));
// Dossier de l'exécutable livré (§ 8.6, point 1). L'exécutable portable est
// une archive auto-extractible : l'application tourne depuis un dossier
// temporaire effacé à la sortie, et le lanceur publie son propre dossier dans
// PORTABLE_EXECUTABLE_DIR avant de la lancer. Hors de ce lanceur, la variable
// est absente ; vide, elle ne désigne aucun dossier. Dans les deux cas, le
// canal rend null. Le gestionnaire est posé avant le chargement de la page,
// qui peut l'appeler dès son premier script.
ipcMain.handle('gtt:dossier-executable', () => process.env.PORTABLE_EXECUTABLE_DIR || null);
function creerFenetre() {
const fenetre = new BrowserWindow({
width: 1366,
height: 768,
webPreferences: {
preload: PRECHARGEMENT,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
// La page ne quitte jamais www/ : toute nouvelle fenêtre est refusée, toute
// navigation annulée, quelle que soit sa cible.
fenetre.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
fenetre.webContents.on('will-navigate', (evenement) => evenement.preventDefault());
fenetre.loadFile(PAGE);
}
// Sans menu posé par l'application, Electron installe le sien : en anglais,
// alors que l'interface est en français (§ 2), et porteur de « Reload » et
// des outils de développement. null le retire, et retire avec lui la barre
// de menu des fenêtres sous Windows et Linux ; il est posé avant la première
// fenêtre, qui naîtrait sinon avec la barre.
app.whenReady().then(() => {
Menu.setApplicationMenu(null);
creerFenetre();
});

19
electron/preload.cjs Normal file
View file

@ -0,0 +1,19 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Préchargement de la coquille (§ 13.1) : le seul pont entre la page et le
// processus principal. Il expose window.gtt et rien d'autre ; la page n'en
// reçoit que des fonctions qui appellent un canal nommé, jamais ipcRenderer
// lui-même.
//
// Un préchargement exécuté en bac à sable est un script CommonJS, d'où
// l'extension .cjs dans un paquet de type module. Son require ne connaît
// qu'electron et quelques modules de Node.
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('gtt', {
plateforme: 'electron',
// Dossier publié par le lanceur portable (§ 8.6, point 1), ou null hors de
// lui ; voir electron/main.js.
dossierExecutable: () => ipcRenderer.invoke('gtt:dossier-executable'),
});

13
index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Le titre est posé par l'application, depuis ses libellés (§ 14.6). -->
<title></title>
<script type="module" src="/src/main.js"></script>
</head>
<body></body>
</html>

6011
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

35
package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "gestion-table-tournante-libre",
"productName": "Gestion table tournante Libre",
"version": "2026.1005.1",
"description": "Placement tournant des participants d’un événement, hors ligne",
"license": "AGPL-3.0-or-later",
"author": "TechnoLibre (http://www.technolibre.ca)",
"private": true,
"type": "module",
"main": "electron/main.js",
"scripts": {
"dev": "vite --mode developpement",
"build": "vite build --mode developpement",
"build:livraison": "node scripts/version.js controler && vite build --mode livraison",
"build:documentation": "vite build --mode documentation",
"test": "vitest run --project node",
"test:surveille": "vitest --project node",
"test:long": "vitest run --project node-long",
"test:navigateur": "vitest run --project navigateur",
"test:tout": "vitest run"
},
"devDependencies": {
"@capacitor/cli": "^8.5.2",
"@capacitor/core": "^8.5.2",
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@vitest/browser-playwright": "^5.0.3",
"electron": "^44.5.1",
"electron-builder": "^26.15.3",
"fast-check": "^4.10.2",
"playwright": "^1.63.0",
"svelte": "^5.57.1",
"vite": "^8.3.2",
"vitest": "^5.0.3"
}
}

171
scripts/construire_windows.sh Executable file
View file

@ -0,0 +1,171 @@
#!/usr/bin/env bash
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
# Construit l'exécutable Windows portable (§ 16) dans dist-electron/, par
# electron-builder exécuté sous podman, puis imprime son chemin, sa taille et
# ce qu'en dit « file ».
#
# scripts/construire_windows.sh refuse si l'exécutable existe
# scripts/construire_windows.sh --reconstruire l'efface, puis le reconstruit
#
# Sortie : 0, l'exécutable est construit ; 1, refus ou échec ; 2, option
# inconnue ; 130 ou 143, interrompu par INT ou TERM. La racine du projet est
# le parent du répertoire du script.
#
# Chaque étape arrête la suite quand elle échoue :
# 1. « node scripts/version.js controler » applique les huit points du
# § 18.5 : une version sans section au changelog ne se construit pas.
# 2. Le nom du livrable est celui que deriver tire de version.json : ce script
# ne le compose pas. electron-builder.config.cjs doit passer le même nom à
# electron-builder ; s'il en passe un autre, tout s'arrête là.
# 3. Un exécutable de ce nom dans dist-electron/ arrête tout (§ 18.2), sauf
# --reconstruire, qui l'efface d'abord. Passé cette étape, un fichier de ce
# nom ne peut venir que de cette exécution.
# 4. La page est construite sur l'hôte, en mode livraison.
# 5. electron-builder emballe www/ et electron/ dans le conteneur.
# 6. Contrôle de fin de construction (§ 18.5) : le seul exécutable apparu au
# premier niveau de dist-electron/ porte le nom de l'étape 2.
# De l'étape 5 à l'acceptation par l'étape 6, toute sortie retire le fichier
# de ce nom : une construction qui échoue, ou que le contrôle refuse, ne
# laisse aucun exécutable sous ce nom.
set -euo pipefail
# Image sans Wine, épinglée par empreinte. La cible portable n'appelle pas
# Wine ; une cible qui l'exige, un installateur, échoue dans cette image au
# lieu de produire le livrable que le § 16 exclut. L'empreinte fige l'image,
# qu'une étiquette ferait avancer à chaque publication.
readonly IMAGE='docker.io/electronuserland/builder@sha256:e41cd059a4fdc0831ccd2a55cb59cbc6b73dc5f44c625778f7fda38874a70df3'
readonly SORTIE='dist-electron'
readonly CONFIGURATION='electron-builder.config.cjs'
refuser() {
printf 'Construction refusée : %s\n' "$1" >&2
exit 1
}
# Noms des fichiers .exe au premier niveau de dist-electron/, un par ligne.
executables() {
find "$SORTIE" -maxdepth 1 -type f -name '*.exe' -printf '%f\n'
}
# Retire le fichier du nom du livrable qu'aucun contrôle de fin n'a accepté.
# electron-builder l'écrit avant d'avoir fini : resté là après un échec ou un
# refus, il serait refusé à l'exécution suivante comme un exécutable déjà
# construit.
retirer_non_controle() {
[[ -e "$cible" ]] || return 0
rm -f -- "$cible"
printf '%s retiré : il n’a pas passé le contrôle de fin de construction.\n' "$cible" >&2
}
reconstruire=0
for argument in "$@"; do
case "$argument" in
--reconstruire) reconstruire=1 ;;
*)
printf 'usage : scripts/construire_windows.sh [--reconstruire]\n' >&2
exit 2
;;
esac
done
racine="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd -- "$racine"
command -v podman >/dev/null || refuser 'podman introuvable : electron-builder s’exécute dans un conteneur.'
# 1. Contrôle de version.
node scripts/version.js controler \
|| refuser 'le contrôle de version du § 18.5 échoue (voir ci-dessus).'
# 2. Nom du livrable. Un seul lancement de node imprime deux lignes : le nom
# que deriver tire de version.json, puis celui que la configuration passe à
# electron-builder. L'étape 1 a déjà appliqué deriver à version.json : un
# échec vient de la configuration. Les deux noms doivent coïncider avant
# l'étape 3, la première qui efface : une configuration refusée n'efface
# rien, même sous --reconstruire, et « rm » ne reçoit que le nom calculé.
noms="$(node -e "
const { deriver } = require('./scripts/version.js');
console.log(deriver(require('./version.json').version).nomFichier);
console.log(require('./$CONFIGURATION').artifactName);
")" || refuser "$CONFIGURATION ne se charge pas."
nom="${noms%%$'\n'*}"
nom_configuration="${noms#*$'\n'}"
[[ "$nom_configuration" == "$nom" ]] \
|| refuser "$CONFIGURATION nomme le livrable $nom_configuration ; version.json donne $nom (§ 18.2)."
cible="$SORTIE/$nom"
# 3. Refus d'écraser.
if [[ -e "$cible" ]]; then
if (( ! reconstruire )); then
refuser "$cible existe déjà. Un numéro ne désigne qu'un binaire (§ 18.2) : changer la version, ou relancer avec --reconstruire si cet exécutable n'a pas quitté cette machine."
fi
rm -f -- "$cible"
fi
# 4. La page.
npm run --silent build:livraison || refuser 'la construction de la page échoue.'
[[ -f www/index.html ]] || refuser 'www/index.html manque après la construction de la page.'
# 5. L'exécutable. Le projet est monté en lecture seule : seuls dist-electron/
# et les volumes nommés sont inscriptibles. Le volume monté sur
# /project/node_modules masque celui de l'hôte, que le « npm ci » du
# conteneur, sous un autre Node, écraserait. « --ignore-scripts » n'exécute
# aucun script d'installation : electron-builder télécharge lui-même
# l'Electron Windows qu'il emballe. /root/.cache garde ce téléchargement et
# les outils d'electron-builder, /root/.npm les archives npm, d'une
# construction à l'autre.
# La commande ne passe aucune variable, et --env-host=false refuse
# l'environnement de l'hôte quel que soit containers.conf, dont un
# env_host = true le transmettrait entier : BUILD_NUMBER et les numéros de
# construction des services d'intégration continue y remplaceraient le
# quatrième champ de la version Windows. podman relaie encore les variables
# de mandataire de l'hôte (http_proxy, https_proxy, ftp_proxy, no_proxy, en
# minuscules comme en majuscules) : derrière un mandataire, npm et
# electron-builder en ont besoin pour télécharger, et elles ne touchent pas
# à la version.
mkdir -p -- "$SORTIE"
declare -A avant=()
while IFS= read -r fichier; do
avant["$fichier"]=1
done < <(executables)
# Le retrait s'arme avant podman. INT et TERM mènent à exit, donc au retrait,
# une fois podman terminé : le shell n'exécute un piège qu'après la commande
# en cours, et podman ne peut plus rien écrire après le retrait. Sans ces deux
# pièges, TERM arrête le shell sur-le-champ, podman continue d'écrire après le
# retrait, et INT reçu par le shell seul n'interrompt rien.
trap retirer_non_controle EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
printf 'Construction de %s sous podman.\n' "$nom"
podman run --rm --env-host=false \
-v "$racine:/project:ro" \
-v "$racine/$SORTIE:/project/$SORTIE" \
-v gestion_table_tournante_libre-node_modules:/project/node_modules \
-v gestion_table_tournante_libre-cache:/root/.cache \
-v gestion_table_tournante_libre-npm:/root/.npm \
-w /project \
"$IMAGE" \
bash -c "npm ci --ignore-scripts --no-audit --no-fund && npx --no-install electron-builder --win --publish never --config $CONFIGURATION" \
|| refuser "electron-builder échoue sous podman (code $?)."
# 6. Contrôle de fin de construction.
apparus=()
while IFS= read -r fichier; do
[[ -n "${avant["$fichier"]-}" ]] || apparus+=("$fichier")
done < <(executables)
if (( ${#apparus[@]} == 0 )); then
refuser "aucun exécutable n'est apparu dans $SORTIE/ ; attendu : $nom."
fi
if (( ${#apparus[@]} > 1 )) || [[ "${apparus[0]}" != "$nom" ]]; then
refuser "exécutable apparu : ${apparus[*]} ; attendu : $nom, le seul nom que version.json donne (§ 18.5)."
fi
# Accepté : l'exécutable reste, même si l'impression qui suit est interrompue.
trap - EXIT INT TERM
printf 'Exécutable : %s\n' "$racine/$cible"
printf 'Taille : %s octets\n' "$(wc -c < "$cible")"
printf 'Type : %s\n' "$(file -b -- "$cible" 2>/dev/null || echo 'inconnu, « file » indisponible')"

View file

@ -0,0 +1,476 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le script de construction de l'exécutable Windows (§ 16, § 18.2, § 18.5) :
// ce qu'il refuse avant de construire, la commande qu'il passe au conteneur,
// le contrôle de fin de construction, et ce qu'il laisse quand la
// construction échoue ou qu'un signal l'interrompt.
//
// Chaque épreuve copie le script et les fichiers qu'il lit dans un arbre
// temporaire, et le lance avec, en tête du PATH, un podman et un npm
// factices. Ils consignent leurs arguments. podman écrit chaque nom de
// FAUX_PRODUIT, un par ligne, dans le dossier de l'hôte qu'il reçoit monté
// sur /project/dist-electron, comme electron-builder y écrirait
// l'exécutable ; il y écrit aussi, à chaque lancement, les deux exécutables
// qu'electron-builder pose toujours sous win-unpacked/, aux profondeurs 2
// et 3 ; puis il sort en FAUX_CODE ; sous FAUX_ATTENTE, il s'annonce sur
// sa sortie et attend une ligne sur son entrée avant d'écrire. npm écrit
// www/index.html, sauf sous FAUX_SANS_PAGE, puis sort en FAUX_CODE_NPM.
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import { once } from 'node:events';
import {
accessSync,
appendFileSync,
chmodSync,
constants,
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { delimiter, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, test } from '../test/lanceur.js';
import { versionVoisine } from '../test/version_voisine.js';
import { deriver } from './version.js';
const RACINE = fileURLToPath(new URL('..', import.meta.url));
const VERSION = JSON.parse(readFileSync(join(RACINE, 'version.json'), 'utf8')).version;
const NOM = deriver(VERSION).nomFichier;
// Le livrable d'une autre version du même jour : il reste dans dist-electron/
// quoi que fasse la construction de la version courante.
const NOM_AUTRE_VERSION = deriver(versionVoisine(VERSION)).nomFichier;
// Délai d'un lancement du script. Il reste sous celui d'une épreuve de
// Vitest, 5 s, qui n'interrompt pas un appel synchrone : un script qui ne
// s'arrête pas fait échouer son épreuve en le disant, au lieu de figer la
// série. Le délai écoulé, le script reçoit KILL : il piège TERM et attend
// la fin de la commande en cours, si bien que TERM n'arrêterait pas un
// script dont un enfant ne finit pas.
const DELAI_MS = 4_000;
// Ce que le script lit, et ce que lit le contrôle de version qu'il lance.
const COPIES = [
'version.json',
'package.json',
'package-lock.json',
'CHANGELOG.md',
'README.md',
'capacitor.config.json',
'electron-builder.config.cjs',
'scripts/version.js',
'scripts/construire_windows.sh',
'src/version.genere.js',
'src/main.js',
];
const LANCE = 'podman factice lancé';
const PODMAN_FACTICE = [
'#!/usr/bin/env bash',
'printf "%s\\0" "$@" > "$JOURNAL/podman"',
'echo podman >> "$JOURNAL/ordre"',
'for argument in "$@"; do',
' case "$argument" in',
' *:/project/dist-electron) sortie="${argument%:/project/dist-electron}" ;;',
' esac',
'done',
'if [[ -n "${FAUX_ATTENTE-}" ]]; then',
` echo '${LANCE}'`,
' read -r _',
'fi',
'mkdir -p "$sortie/win-unpacked/resources"',
'printf MZ > "$sortie/win-unpacked/Gestion table tournante Libre.exe"',
'printf MZ > "$sortie/win-unpacked/resources/elevate.exe"',
'while IFS= read -r produit; do',
' [[ -z "$produit" ]] || printf MZ > "$sortie/$produit"',
'done <<< "${FAUX_PRODUIT-}"',
'exit "${FAUX_CODE:-0}"',
'',
].join('\n');
const NPM_FACTICE = [
'#!/usr/bin/env bash',
'printf "%s\\0" "$@" > "$JOURNAL/npm"',
'echo npm >> "$JOURNAL/ordre"',
'[[ -n "${FAUX_SANS_PAGE-}" ]] || { mkdir -p www && echo "<!doctype html>" > www/index.html; }',
'exit "${FAUX_CODE_NPM:-0}"',
'',
].join('\n');
// node qui se consigne dans l'ordre des lancements, puis passe la main au
// vrai : sert là où le PATH est réduit à bin/.
const NODE_CONSIGNE = [
'#!/usr/bin/env bash',
'echo node >> "$JOURNAL/ordre"',
`exec '${process.execPath}' "$@"`,
'',
].join('\n');
// Outils que le script appelle, hors podman, npm et node. Un PATH réduit à
// bin/ en reçoit des liens : seul podman y manque.
const OUTILS = ['bash', 'dirname', 'find', 'mkdir', 'rm', 'wc', 'file'];
// Chemin d'un exécutable dans le PATH de l'épreuve, ou null.
function chercher(nom) {
for (const dossier of (process.env.PATH ?? '').split(delimiter)) {
try {
accessSync(join(dossier, nom), constants.X_OK);
return join(dossier, nom);
} catch {
// Absent de ce dossier : le suivant.
}
}
return null;
}
const lireSiPresent = (chemin) => (existsSync(chemin) ? readFileSync(chemin, 'utf8') : '');
// Arguments consignés par un factice, ou null s'il n'a pas été lancé.
function argumentsDe(journal, factice) {
const texte = lireSiPresent(join(journal, factice));
return texte === '' ? null : texte.split('\0').slice(0, -1);
}
// Contenu de chaque fichier .exe au premier niveau de dist-electron/.
function executables(projet) {
const sortie = join(projet, 'dist-electron');
if (!existsSync(sortie)) return {};
return Object.fromEntries(
readdirSync(sortie)
.filter((nom) => nom.endsWith('.exe'))
.sort()
.map((nom) => [nom, readFileSync(join(sortie, nom), 'utf8')]),
);
}
// Un arbre neuf, prêt au lancement : le projet recopié, bin/ et ses
// factices, le journal, l'environnement du script. produit est un nom ou une
// liste de noms que podman écrit ; page: false et codeNpm règlent npm ;
// attente règle podman. sansPodman réduit le PATH à bin/, qui reçoit les
// OUTILS et NODE_CONSIGNE, sans podman. preparer reçoit la racine du projet.
function preparerArbre({ produit, code, codeNpm, page = true, attente = false, sansPodman = false, preparer } = {}) {
const base = mkdtempSync(join(tmpdir(), 'construire-windows-'));
const projet = join(base, 'projet');
const bin = join(base, 'bin');
const journal = join(base, 'journal');
for (const chemin of COPIES) {
mkdirSync(dirname(join(projet, chemin)), { recursive: true });
copyFileSync(join(RACINE, chemin), join(projet, chemin));
}
mkdirSync(bin);
mkdirSync(journal);
const factices = sansPodman
? [['npm', NPM_FACTICE], ['node', NODE_CONSIGNE]]
: [['podman', PODMAN_FACTICE], ['npm', NPM_FACTICE]];
for (const [nom, contenu] of factices) {
writeFileSync(join(bin, nom), contenu);
chmodSync(join(bin, nom), 0o755);
}
if (sansPodman) {
for (const nom of OUTILS) {
const chemin = chercher(nom);
if (chemin !== null) symlinkSync(chemin, join(bin, nom));
}
}
preparer?.(projet);
const environnement = {
...process.env,
PATH: sansPodman ? bin : [bin, dirname(process.execPath), process.env.PATH].join(delimiter),
JOURNAL: journal,
};
for (const cle of ['FAUX_PRODUIT', 'FAUX_CODE', 'FAUX_ATTENTE', 'FAUX_SANS_PAGE', 'FAUX_CODE_NPM']) {
delete environnement[cle];
}
if (produit !== undefined) environnement.FAUX_PRODUIT = [produit].flat().join('\n');
if (code !== undefined) environnement.FAUX_CODE = String(code);
if (codeNpm !== undefined) environnement.FAUX_CODE_NPM = String(codeNpm);
if (!page) environnement.FAUX_SANS_PAGE = '1';
if (attente) environnement.FAUX_ATTENTE = '1';
return { base, projet, journal, environnement, script: join(projet, 'scripts', 'construire_windows.sh') };
}
// Lance le script dans un arbre neuf, puis rend ce qu'il a imprimé, ce que
// les factices ont consigné et les exécutables de dist-electron/. Les
// paramètres sont ceux de preparerArbre, plus options, les arguments du
// script, et relever, appelé sur la racine du projet avant son retrait :
// son résultat est rendu sous « releve ».
function construire({ options = [], relever, ...parametres } = {}) {
const arbre = preparerArbre(parametres);
try {
const resultat = spawnSync(arbre.script, options, {
cwd: arbre.base,
encoding: 'utf8',
env: arbre.environnement,
timeout: DELAI_MS,
killSignal: 'SIGKILL',
});
assert.notEqual(
resultat.error?.code,
'ETIMEDOUT',
`scripts/construire_windows.sh ne s'arrête pas en ${DELAI_MS} ms :\n${resultat.stderr}`,
);
return {
statut: resultat.status,
sortie: resultat.stdout,
erreurs: `${resultat.error?.message ?? ''}${resultat.stderr}`,
projet: arbre.projet,
ordre: lireSiPresent(join(arbre.journal, 'ordre')).split('\n').filter(Boolean),
npm: argumentsDe(arbre.journal, 'npm'),
podman: argumentsDe(arbre.journal, 'podman'),
executables: executables(arbre.projet),
releve: relever?.(arbre.projet),
};
} finally {
rmSync(arbre.base, { recursive: true, force: true });
}
}
const ecrireExecutable = (contenu, nom = NOM) => (projet) => {
mkdirSync(join(projet, 'dist-electron'), { recursive: true });
writeFileSync(join(projet, 'dist-electron', nom), contenu);
};
// La configuration recopiée tape le nom du livrable : la ligne ajoutée
// remplace celui qu'elle tient de deriver.
const taperLeNom = (nom) => (projet) =>
appendFileSync(join(projet, 'electron-builder.config.cjs'), `module.exports.artifactName = ${JSON.stringify(nom)};\n`);
// Une construction réussie, partagée par les épreuves qui la lisent.
let reussie;
const constructionReussie = () => (reussie ??= construire({ produit: NOM }));
describe('construction Windows : refus avant de construire', () => {
test('une version sans section au changelog ne se construit pas (§ 16)', () => {
const r = construire({
preparer: (projet) =>
writeFileSync(join(projet, 'CHANGELOG.md'), '# Changelog — Gestion table tournante Libre\n'),
});
assert.equal(r.statut, 1, r.erreurs);
assert.match(r.erreurs, /^3\. CHANGELOG\.md/m);
assert.match(r.erreurs, /Construction refusée/);
assert.deepEqual(r.ordre, []);
});
test("un exécutable du même nom n'est pas écrasé (§ 18.2)", () => {
const r = construire({ produit: NOM, preparer: ecrireExecutable('ancien') });
assert.equal(r.statut, 1, r.erreurs);
assert.ok(r.erreurs.includes(`dist-electron/${NOM}`), r.erreurs);
assert.match(r.erreurs, /--reconstruire/);
assert.deepEqual(r.ordre, []);
assert.deepEqual(r.executables, { [NOM]: 'ancien' });
});
test('une option inconnue sort en 2 et rappelle l’usage', () => {
const r = construire({ options: ['--forcer'] });
assert.equal(r.statut, 2, r.erreurs);
assert.match(r.erreurs, /usage .*--reconstruire/);
assert.deepEqual(r.ordre, []);
});
test('sans podman dans le PATH, refuse avant le contrôle de version', () => {
const r = construire({ sansPodman: true });
assert.equal(r.statut, 1, r.erreurs);
assert.match(r.erreurs, /podman introuvable/);
assert.deepEqual(r.ordre, []);
});
test("une configuration d'electron-builder qui ne se charge pas arrête tout, avant la page", () => {
const r = construire({
preparer: (projet) =>
writeFileSync(join(projet, 'electron-builder.config.cjs'), "throw new Error('configuration illisible');\n"),
});
assert.equal(r.statut, 1, r.erreurs);
assert.match(r.erreurs, /electron-builder\.config\.cjs ne se charge pas/);
assert.deepEqual(r.ordre, []);
});
test('une configuration qui nomme le livrable autrement que deriver depuis version.json arrête tout, avant la page (§ 18.2)', () => {
// podman produirait le nom tapé : seul le nom calculé depuis version.json
// distingue cette construction d'une construction conforme.
const r = construire({ produit: NOM_AUTRE_VERSION, preparer: taperLeNom(NOM_AUTRE_VERSION) });
assert.equal(r.statut, 1, r.erreurs);
assert.ok(r.erreurs.includes(NOM_AUTRE_VERSION) && r.erreurs.includes(NOM), r.erreurs);
assert.deepEqual(r.ordre, []);
assert.deepEqual(r.executables, {});
});
test("--reconstruire n'efface rien quand la configuration nomme le livrable autrement : ni l'exécutable, ni le fichier qu'elle désigne", () => {
const tape = '../version.json';
const r = construire({
options: ['--reconstruire'],
preparer: (projet) => {
ecrireExecutable('ancien')(projet);
taperLeNom(tape)(projet);
},
relever: (projet) => existsSync(join(projet, 'version.json')),
});
assert.equal(r.statut, 1, r.erreurs);
assert.ok(r.erreurs.includes(tape) && r.erreurs.includes(NOM), r.erreurs);
assert.deepEqual(r.ordre, []);
assert.deepEqual(r.executables, { [NOM]: 'ancien' });
assert.equal(r.releve, true, 'version.json effacé');
});
});
describe('construction Windows : la page', () => {
test('une page qui ne se construit pas arrête tout, avant podman', () => {
const r = construire({ produit: NOM, codeNpm: 1 });
assert.equal(r.statut, 1, r.erreurs);
assert.match(r.erreurs, /la construction de la page échoue/);
assert.deepEqual(r.ordre, ['npm']);
assert.deepEqual(r.executables, {});
});
test('une page absente après sa construction arrête tout, avant podman', () => {
const r = construire({ produit: NOM, page: false });
assert.equal(r.statut, 1, r.erreurs);
assert.match(r.erreurs, /www\/index\.html manque/);
assert.deepEqual(r.ordre, ['npm']);
assert.deepEqual(r.executables, {});
});
});
describe('construction Windows : la construction', () => {
test("construit la page puis l'exécutable, et imprime son chemin, sa taille et son type", () => {
const r = constructionReussie();
assert.equal(r.statut, 0, r.erreurs);
assert.deepEqual(r.ordre, ['npm', 'podman']);
assert.deepEqual(r.npm, ['run', '--silent', 'build:livraison']);
assert.deepEqual(r.executables, { [NOM]: 'MZ' });
assert.ok(r.sortie.includes(join(r.projet, 'dist-electron', NOM)), r.sortie);
assert.match(r.sortie, /^Taille +: 2 octets$/m);
assert.match(r.sortie, /^Type +: \S/m);
});
test("le conteneur : image épinglée, projet en lecture seule, node_modules de l'hôte masqué, aucune variable de l'hôte, quel que soit containers.conf", () => {
const { statut, erreurs, projet, podman } = constructionReussie();
assert.equal(statut, 0, erreurs);
assert.equal(podman[0], 'run');
// Tout ce qui suit l'image est la commande du conteneur : les options de
// podman la précèdent toutes.
const image = podman.findIndex((a) => /^docker\.io\/electronuserland\/builder@sha256:[0-9a-f]{64}$/.test(a));
assert.notEqual(image, -1, 'image non épinglée par empreinte');
assert.deepEqual(podman.slice(image + 1, -1), ['bash', '-c']);
const options = podman.slice(1, image);
assert.ok(options.includes('--rm'));
assert.ok(options.includes(`${projet}:/project:ro`), 'projet non monté en lecture seule');
assert.ok(options.includes(`${projet}/dist-electron:/project/dist-electron`));
assert.ok(
options.some((a) => /^[\w.-]+:\/project\/node_modules$/.test(a)),
'node_modules non masqué par un volume nommé',
);
// Aucune variable passée, et l'environnement de l'hôte refusé sur la ligne
// de commande : un env_host = true de containers.conf le transmettrait
// entier, BUILD_NUMBER compris.
assert.deepEqual(options.filter((a) => a === '-e' || a.startsWith('--env')), ['--env-host=false']);
const commande = podman.at(-1);
assert.match(commande, /npm ci --ignore-scripts/);
assert.match(
commande,
/electron-builder --win --publish never --config electron-builder\.config\.cjs/,
);
});
test("accepte le livrable quand dist-electron/ garde celui d'une autre version et les exécutables de win-unpacked/", () => {
const r = construire({ produit: NOM, preparer: ecrireExecutable('autre', NOM_AUTRE_VERSION) });
assert.equal(r.statut, 0, r.erreurs);
assert.deepEqual(r.executables, { [NOM]: 'MZ', [NOM_AUTRE_VERSION]: 'autre' });
});
test("--reconstruire remplace un exécutable du même nom", () => {
const r = construire({ options: ['--reconstruire'], produit: NOM, preparer: ecrireExecutable('ancien') });
assert.equal(r.statut, 0, r.erreurs);
assert.deepEqual(r.executables, { [NOM]: 'MZ' });
});
});
describe('construction Windows : contrôle de fin de construction (§ 18.5)', () => {
test("refuse un exécutable dont le nom ne dérive pas de version.json", () => {
const autre = 'Gestion table tournante Libre.exe';
const r = construire({ produit: autre });
assert.equal(r.statut, 1, r.sortie);
assert.ok(r.erreurs.includes(autre) && r.erreurs.includes(NOM), r.erreurs);
assert.doesNotMatch(r.sortie, /^Taille/m);
});
test("refuse quand aucun exécutable n'apparaît", () => {
const r = construire();
assert.equal(r.statut, 1, r.sortie);
assert.match(r.erreurs, /aucun exécutable/);
});
test("un échec sous podman ne laisse aucun exécutable sous ce nom, même avec --reconstruire", () => {
const r = construire({ options: ['--reconstruire'], code: 7, preparer: ecrireExecutable('ancien') });
assert.equal(r.statut, 1, r.sortie);
assert.match(r.erreurs, /podman.*7/);
assert.deepEqual(r.executables, {});
});
test("un échec sous podman retire ce qu'electron-builder a déjà écrit sous ce nom, et rien d'autre", () => {
const r = construire({ produit: NOM, code: 7, preparer: ecrireExecutable('autre', NOM_AUTRE_VERSION) });
assert.equal(r.statut, 1, r.sortie);
assert.match(r.erreurs, /podman.*7/);
assert.ok(r.erreurs.includes(`dist-electron/${NOM} retiré`), r.erreurs);
assert.deepEqual(r.executables, { [NOM_AUTRE_VERSION]: 'autre' });
});
test("deux exécutables apparus : refusé, et celui qui porte le nom attendu est retiré", () => {
const autre = 'Gestion table tournante Libre.exe';
const r = construire({ produit: [NOM, autre] });
assert.equal(r.statut, 1, r.sortie);
assert.ok(r.erreurs.includes(autre) && r.erreurs.includes(NOM), r.erreurs);
assert.ok(r.erreurs.includes(`dist-electron/${NOM} retiré`), r.erreurs);
assert.equal(r.executables[NOM], undefined, `dist-electron/${NOM} reste après un refus`);
assert.doesNotMatch(r.sortie, /^Taille/m);
});
});
describe('construction Windows : interruption', () => {
// Le signal ne vise que le shell, comme un kill depuis un autre terminal.
// podman écrit après l'avoir reçu, comme electron-builder qui achève son
// travail : l'épreuve ne réagit qu'à des événements, l'annonce du podman
// factice puis la fin du script, et le délai ne fait que les borner.
for (const [signal, code] of [['SIGTERM', 143], ['SIGINT', 130]]) {
test(`${signal} reçu pendant podman : le script attend sa fin, retire ce qu'il a écrit et sort en ${code}`, async () => {
const arbre = preparerArbre({ produit: NOM, attente: true });
const script = spawn(arbre.script, [], { cwd: arbre.base, env: arbre.environnement });
script.stdin.on('error', () => {});
let sortie = '';
let erreurs = '';
script.stderr.setEncoding('utf8').on('data', (morceau) => (erreurs += morceau));
const lance = new Promise((resoudre) => {
script.stdout.setEncoding('utf8').on('data', (morceau) => {
sortie += morceau;
if (sortie.includes(LANCE)) resoudre();
});
});
const fin = once(script, 'close', { signal: AbortSignal.timeout(DELAI_MS) }).catch((erreur) => {
throw new Error(`scripts/construire_windows.sh ne s'arrête pas en ${DELAI_MS} ms (${erreur.name})\n${erreurs}`);
});
fin.catch(() => {});
try {
await Promise.race([lance, fin]);
assert.ok(sortie.includes(LANCE), `podman factice jamais lancé\n${erreurs}`);
script.kill(signal);
script.stdin.end('\n');
const [statut, signalDeFin] = await fin;
assert.deepEqual({ statut, signalDeFin }, { statut: code, signalDeFin: null }, erreurs);
assert.ok(erreurs.includes(`dist-electron/${NOM} retiré`), erreurs);
assert.deepEqual(executables(arbre.projet), {});
} finally {
script.stdin.destroy();
script.kill('SIGKILL');
rmSync(arbre.base, { recursive: true, force: true });
}
});
}
});

View file

@ -0,0 +1,62 @@
#!/usr/bin/env bash
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
# Imprime la sortie de la démo de l'implémentation de référence de PCG32, que
# rejoue src/demo/prng.test.js (§ 14.11 : un vecteur pris hors du code éprouvé).
#
# scripts/oracle/pcg32_reference.sh
#
# Le script récupère pcg-c-basic en profondeur 1 dans un répertoire
# temporaire, au commit que cite src/demo/prng.test.js, et imprime le hachage
# du commit extrait. Il compile pcg32-demo.c avec pcg_basic.c (gcc -O2), puis
# exécute la démo sans argument : elle ensemence par
# pcg32_srandom_r(&rng, 42u, 54u) et imprime cinq tours de tirages. Le commit
# est demandé par son hachage, et non comme tête d'une branche : le dépôt peut
# avancer sans changer ce que le script compile. Le répertoire temporaire est
# retiré à la sortie. La récupération demande un accès réseau, et échoue
# plutôt que de demander un identifiant.
#
# Sortie : 0, la démo a tourné ; 1, outil absent, récupération, compilation ou
# exécution en échec ; 2, argument reçu.
set -euo pipefail
readonly DEPOT='https://github.com/imneme/pcg-c-basic'
readonly COMMIT='bc39cd76ac3d541e618606bcc6e1e5ba5e5e6aa3'
refuser() {
printf 'Oracle PCG32 : %s\n' "$1" >&2
exit 1
}
if (($# > 0)); then
printf 'usage : scripts/oracle/pcg32_reference.sh\n' >&2
exit 2
fi
for outil in git gcc; do
command -v "$outil" >/dev/null || refuser "$outil introuvable."
done
temporaire="$(mktemp -d)"
trap 'rm -rf -- "$temporaire"' EXIT
source_c="$temporaire/pcg-c-basic"
demo="$temporaire/pcg32-demo"
# Un dépôt vide reçoit le seul commit COMMIT, sans historique, et l'extrait.
recuperer() {
git init --quiet -- "$source_c" &&
GIT_TERMINAL_PROMPT=0 git -C "$source_c" fetch --quiet --depth 1 -- "$DEPOT" "$COMMIT" &&
git -C "$source_c" checkout --quiet FETCH_HEAD
}
recuperer || refuser "récupération de $DEPOT au commit $COMMIT impossible."
commit="$(git -C "$source_c" rev-parse HEAD)"
gcc -O2 -o "$demo" "$source_c/pcg32-demo.c" "$source_c/pcg_basic.c" \
|| refuser 'compilation de pcg32-demo.c et pcg_basic.c impossible.'
printf 'dépôt : %s\n' "$DEPOT"
printf 'commit : %s\n' "$commit"
printf 'compilation : gcc -O2 pcg32-demo.c pcg_basic.c\n'
printf 'exécution : pcg32-demo, sans argument\n\n'
"$demo" || refuser 'la démo a échoué.'

724
scripts/version.js Normal file
View file

@ -0,0 +1,724 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La version datée (§ 18). version.json en est l'unique source ; ce script en
// dérive tout le reste et contrôle que rien n'en diverge.
//
// Une version s'écrit sous quatre formes :
// affichée AAAA.MM.JJ.NN, quatre champs à longueur fixe, rang de 01 à 99 ;
// son ordre alphabétique est l'ordre chronologique ;
// technique AAAA.(MM×100+JJ).NN, trois entiers sans zéro de tête, la forme
// qu'exigent package.json et electron-builder ;
// Windows la forme technique suivie de « .0 » : les quatre champs de la
// ressource de version de l'exécutable, de 16 bits chacun ;
// nom gestion_table_tournante_libre_v<AAAA>_<MM>_<JJ>_<NN>.exe, les
// points devenus soulignés, seul point restant celui de
// l'extension.
// Le jour étant inférieur à 100, MM×100+JJ se redécompose d'une seule façon :
// la dérivation est monotone et bijective.
//
// En ligne de commande, la racine du projet est le parent du répertoire du
// script :
// node scripts/version.js engendrer réécrit src/version.genere.js, le
// champ version de package.json et les
// deux champs de version du paquet
// racine de package-lock.json ;
// node scripts/version.js controler applique les huit points du § 18.5 à
// la date civile locale de la machine ;
// imprime les échecs et sort en code 1,
// ou sort en code 0.
// Ce script n'appartient ni au moteur ni au générateur de démonstrations, et
// l'interdit de lecture d'horloge du § 14.7 ne le vise pas : dateCivileLocale,
// qu'appelle la commande « controler », lit l'horloge de la machine.
import { mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
// Le nom du livrable (§ 18.2) ne s'écrit qu'ici : le produit, « _v », la
// version dont les points deviennent des soulignés, l'extension. nomDuLivrable
// sert à deriver pour une version et au gabarit pour les champs nommés ; le
// point 7 compare chaque nom cité à ce calcul.
const PRODUIT = 'gestion_table_tournante_libre';
const PREFIXE_NOM = `${PRODUIT}_v`;
const EXTENSION = '.exe';
const nomDuLivrable = (version) => `${PREFIXE_NOM}${version.replaceAll('.', '_')}${EXTENSION}`;
const GABARIT_NOM = nomDuLivrable('<AAAA>.<MM>.<JJ>.<NN>');
const UN_JOUR_MS = 86_400_000;
const MOIS = [
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
];
// Forme affichée AAAA.MM.JJ.NN. Le rang s'y lit aussi sur trois chiffres ou
// plus sans zéro de tête : la forme est alors reconnue, et le refus nomme le
// plafond du rang. Un rang « 001 » reste une faute de forme.
const FORME_AFFICHEE = /^(\d{4})\.(\d{2})\.(\d{2})\.(\d{2}|[1-9]\d{2,})$/;
// Trois entiers sans zéro de tête ; MM×100+JJ s'écrit sur trois ou quatre
// chiffres, le rang sur un ou deux.
const FORME_TECHNIQUE = /^(\d{4})\.([1-9]\d{2,3})\.([1-9]\d?)$/;
const deuxChiffres = (n) => String(n).padStart(2, '0');
function estBissextile(annee) {
return (annee % 4 === 0 && annee % 100 !== 0) || annee % 400 === 0;
}
function joursDuMois(annee, mois) {
if (mois === 2) return estBissextile(annee) ? 29 : 28;
return mois === 4 || mois === 6 || mois === 9 || mois === 11 ? 30 : 31;
}
// Lève quand { annee, mois, jour } n'est pas une date du calendrier
// grégorien dont l'année s'écrit sur quatre chiffres sans zéro de tête — la
// forme technique écrit l'année en entier, et un zéro de tête s'y perdrait.
// « contexte » ouvre le message.
function exigerDate({ annee, mois, jour }, contexte) {
if (!Number.isInteger(annee) || annee < 1000 || annee > 9999) {
throw new RangeError(`${contexte} : année ${annee} hors de 1000 à 9999`);
}
if (!Number.isInteger(mois) || mois < 1 || mois > 12) {
throw new RangeError(`${contexte} : mois ${mois} hors de 1 à 12`);
}
const dernier = joursDuMois(annee, mois);
if (!Number.isInteger(jour) || jour < 1 || jour > dernier) {
throw new RangeError(
`${contexte} : jour ${jour} hors de 1 à ${dernier} en ${MOIS[mois - 1]} ${annee}`,
);
}
}
/**
* Formes dérivées d'une version affichée AAAA.MM.JJ.NN : { affichee,
* technique, windows, nomFichier, annee, mois, jour, rang }, les quatre
* derniers en entiers. Lève sur une forme non conforme, une date civile
* invalide, un rang hors de 1 à 99.
*/
export function deriver(versionAffichee) {
if (typeof versionAffichee !== 'string') {
throw new TypeError(`version attendue sous forme de chaîne, reçu ${typeof versionAffichee}`);
}
const contexte = `version « ${versionAffichee} »`;
const champs = FORME_AFFICHEE.exec(versionAffichee);
if (champs === null) {
throw new Error(
`${contexte} : forme attendue AAAA.MM.JJ.NN, quatre champs à longueur fixe (§ 18.1)`,
);
}
const [annee, mois, jour, rang] = champs.slice(1).map(Number);
exigerDate({ annee, mois, jour }, contexte);
if (rang > 99) throw new RangeError(`${contexte} : rang ${rang}, le rang plafonne à 99 (§ 18.1)`);
if (rang < 1) throw new RangeError(`${contexte} : rang 00 hors de 01 à 99`);
const technique = `${annee}.${mois * 100 + jour}.${rang}`;
return {
affichee: versionAffichee,
technique,
windows: `${technique}.0`,
nomFichier: nomDuLivrable(versionAffichee),
annee,
mois,
jour,
rang,
};
}
/**
* Version affichée d'une version technique AAAA.(MM×100+JJ).NN. Lève quand
* la chaîne ne désigne pas exactement une version : forme non conforme, zéro
* de tête, date civile invalide, rang hors de 1 à 99. Sur une chaîne
* acceptée, deriver(redecomposer(t)).technique === t.
*/
export function redecomposer(versionTechnique) {
if (typeof versionTechnique !== 'string') {
throw new TypeError(
`version technique attendue sous forme de chaîne, reçu ${typeof versionTechnique}`,
);
}
const contexte = `version technique « ${versionTechnique} »`;
const champs = FORME_TECHNIQUE.exec(versionTechnique);
if (champs === null) {
throw new Error(
`${contexte} : forme attendue AAAA.(MM×100+JJ).NN, trois entiers sans zéro de tête, rang de 1 à 99 (§ 18.1)`,
);
}
const [annee, moisJour, rang] = champs.slice(1).map(Number);
const mois = Math.floor(moisJour / 100);
const jour = moisJour % 100;
exigerDate({ annee, mois, jour }, contexte);
return `${annee}.${deuxChiffres(mois)}.${deuxChiffres(jour)}.${deuxChiffres(rang)}`;
}
/**
* Date en toutes lettres du titre d'une section du changelog : « 9 mars
* 2031 », « 1er janvier 2027 ». Lève sur une date hors du calendrier.
*/
export function dateEnLettres({ annee, mois, jour }) {
exigerDate({ annee, mois, jour }, 'date');
return `${jour === 1 ? '1er' : jour} ${MOIS[mois - 1]} ${annee}`;
}
// --- Le module engendré et les recopies ------------------------------------
const MODULE = 'src/version.genere.js';
const CONSEIL_ENGENDRER = 'lancer « node scripts/version.js engendrer »';
const EN_TETE_LICENCE = [
'// © 2026 TechnoLibre (http://www.technolibre.ca)',
'// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)',
];
/**
* Contenu exact de src/version.genere.js pour une version affichée :
* l'en-tête de licence, une ligne vide, puis la constante VERSION sous ses
* formes affichée et technique. Le module ne porte que la version ; la
* provenance de la construction vient du mode de Vite.
*/
export function engendrerModule(versionAffichee) {
const { affichee, technique } = deriver(versionAffichee);
return [
...EN_TETE_LICENCE,
'',
'// Engendré par scripts/version.js depuis version.json — ne pas modifier.',
'export const VERSION = Object.freeze({',
` affichee: '${affichee}',`,
` technique: '${technique}',`,
'});',
'',
].join('\n');
}
// JSON écrit comme npm l'écrit : indentation de deux espaces, saut de ligne
// final.
const enJson = (valeur) => `${JSON.stringify(valeur, null, 2)}\n`;
// Contenu d'un fichier relatif à la racine, ou null s'il n'existe pas.
function lireTexte(racine, chemin) {
try {
return readFileSync(join(racine, chemin), 'utf8');
} catch (erreur) {
if (erreur.code === 'ENOENT') return null;
throw erreur;
}
}
// Valeur JSON d'un fichier relatif à la racine. Lève en nommant le fichier
// quand il est illisible, et quand il manque, avec « raisonSiAbsent » pour
// suite du message.
function lireJson(racine, chemin, raisonSiAbsent) {
const texte = lireTexte(racine, chemin);
if (texte === null) throw new Error(`${chemin} absent : ${raisonSiAbsent}`);
try {
return JSON.parse(texte);
} catch (erreur) {
throw new Error(`${chemin} illisible : ${erreur.message}`);
}
}
// Version affichée que porte version.json, non vérifiée. Lève sur un
// fichier absent, illisible ou sans champ « version » textuel.
function lireVersion(racine) {
const donnees = lireJson(racine, 'version.json', "c'est l'unique source de la version (§ 18.3)");
if (typeof donnees?.version !== 'string') {
throw new Error('version.json : champ « version » absent ou non textuel');
}
return donnees.version;
}
// Formes dérivées de la version de version.json. Chaque refus nomme
// version.json.
function versionDeLaSource(racine) {
const affichee = lireVersion(racine);
try {
return deriver(affichee);
} catch (erreur) {
throw new Error(`version.json : ${erreur.message}`, { cause: erreur });
}
}
// Vrai pour un objet JSON, ni tableau ni null.
const estObjet = (valeur) =>
typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
const RAISON_PAQUET = 'il porte la forme technique de la version (§ 18.3)';
const RAISON_VERROU = `« npm install » le crée, puis ${CONSEIL_ENGENDRER}`;
/**
* Réécrit, depuis version.json, le module engendré, le champ version de
* package.json et les champs version et packages[""].version de
* package-lock.json, en forme technique. Tout est lu et vérifié avant la
* première écriture : une version non conforme, un package.json ou un verrou
* absent font lever sans rien écrire. Un fichier déjà à jour n'est pas
* réécrit. Rend les chemins réécrits, relatifs à la racine.
*/
export function engendrer(racine) {
const { affichee, technique } = versionDeLaSource(racine);
const paquet = lireJson(racine, 'package.json', RAISON_PAQUET);
if (!estObjet(paquet)) throw new Error('package.json : un objet JSON est attendu');
const verrou = lireJson(racine, 'package-lock.json', RAISON_VERROU);
if (!estObjet(verrou) || !estObjet(verrou.packages) || !estObjet(verrou.packages[''])) {
throw new Error('package-lock.json : paquet racine packages[""] absent');
}
const cibles = [
[MODULE, engendrerModule(affichee)],
['package.json', enJson({ ...paquet, version: technique })],
[
'package-lock.json',
enJson({
...verrou,
version: technique,
packages: { ...verrou.packages, '': { ...verrou.packages[''], version: technique } },
}),
],
];
const reecrits = [];
for (const [chemin, contenu] of cibles) {
if (lireTexte(racine, chemin) === contenu) continue;
mkdirSync(dirname(join(racine, chemin)), { recursive: true });
writeFileSync(join(racine, chemin), contenu);
reecrits.push(chemin);
}
return reecrits;
}
// --- Le contrôle du § 18.5 ------------------------------------------------
// Les deux lignes obligatoires de la section de tête du changelog (§ 18.4) :
// le libellé en gras, les deux-points, puis une réponse.
const LIGNES_OBLIGATOIRES = [
['**Version de format des fichiers**', /^\*\*Version de format des fichiers\*\*\s*:\s*\S/],
['**En remplaçant**', /^\*\*En remplaçant\*\*\s*:\s*\S/],
];
// Périmètre du balayage du point 6, posé ici et non après coup : les fichiers
// de la racine, et les arbres des sources, des scripts, de la coquille et des
// épreuves. Seuls ces arbres sont parcourus : les dépendances et les sorties
// de construction (www/, dist*/, android/) n'en font pas partie, et un
// répertoire node_modules n'est jamais descendu. En sont exclus :
// - version.json, package-lock.json, le module engendré et le changelog, où
// la version se recopie par construction et que les points 1 à 3
// contrôlent ;
// - les documents (*.md), dont le point 7 contrôle les noms cités ;
// - les épreuves de la dérivation, qui citent des versions par nécessité ;
// - les données d'épreuve, sous test/fixtures/.
// package.json reste balayé : la forme technique y est à sa place, la forme
// affichée non.
const ARBRES_BALAYES = ['src', 'scripts', 'electron', 'test'];
const FICHIERS_EXCLUS = new Set([
'version.json',
'package-lock.json',
MODULE,
'CHANGELOG.md',
'scripts/version.test.js',
]);
const REPERTOIRES_EXCLUS = new Set(['test/fixtures']);
const DOCUMENT = /\.md$/i;
const ARBRE_DES_SOURCES = 'src/';
// Une chaîne de chaque forme, bornée : ni chiffre ni point devant, aucun
// chiffre derrière. La forme Windows contient la forme technique.
const CHAINE_AFFICHEE = /(?<![\d.])\d{4}\.\d{2}\.\d{2}\.\d{2}(?!\d)/g;
const CHAINE_TECHNIQUE = /(?<![\d.])\d{4}\.\d{3,4}\.\d{1,2}(?!\d)/g;
// Documents du point 7, à la racine.
const DOCUMENTS_FIXES = ['README.md', 'CHANGELOG.md'];
const GUIDE = /^GUIDE-.*\.md$/;
const LITTERAL_NON_SUBSTITUE = /#_#/g;
// Un nom d'exécutable cité : depuis le nom du produit jusqu'à la première
// extension, sans blanc ni délimiteur de Markdown ou de code. La casse est
// ignorée à la recherche, pas à la conformité. Le préfixe seul, sans
// extension, désigne une famille de fichiers et n'est pas un nom.
const NOM_CITE = new RegExp(
`${RegExp.escape(PRODUIT)}[^\\s\`'"()[\\]{}|*]*?${RegExp.escape(EXTENSION)}(?!\\w)`,
'gi',
);
// Vrai quand le nom cité est le gabarit, ou le nom que deriver calcule pour
// la version dont il porte les chiffres, lus après le préfixe. Tout nom admis
// sort ainsi de deriver, à l'octet.
function nomConforme(nom) {
if (nom === GABARIT_NOM) return true;
const chiffres = nom.slice(PREFIXE_NOM.length).match(/\d+/g) ?? [];
return essayer(() => deriver(chiffres.join('.')).nomFichier) === nom;
}
// Résultat de « calcul », ou null s'il lève.
function essayer(calcul) {
try {
return calcul();
} catch {
return null;
}
}
// Ordre de deux versions dérivées, comparées champ par champ comme quatre
// entiers : négatif quand a précède b.
function comparerVersions(a, b) {
return a.annee - b.annee || a.mois - b.mois || a.jour - b.jour || a.rang - b.rang;
}
// Rang d'une date civile dans le calendrier, en jours : la différence de
// deux rangs compte les jours qui séparent les deux dates.
const numeroDeJour = ({ annee, mois, jour }) => Date.UTC(annee, mois - 1, jour) / UN_JOUR_MS;
// { annee, mois, jour } d'une date civile AAAA-MM-JJ ; lève sur toute autre
// forme et sur une date hors du calendrier.
function lireDateCivile(dateCivile) {
const champs =
typeof dateCivile === 'string' ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateCivile) : null;
if (champs === null) {
throw new TypeError(`dateCivile attendue sous la forme AAAA-MM-JJ, reçu ${String(dateCivile)}`);
}
const [annee, mois, jour] = champs.slice(1).map(Number);
exigerDate({ annee, mois, jour }, `dateCivile « ${dateCivile} »`);
return { annee, mois, jour };
}
// Sections du changelog : chaque titre « ## », avec son numéro de ligne, son
// texte et les lignes de son corps. Un bloc de code, de la ligne qui l'ouvre
// par ``` ou ~~~ à celle qui le ferme, n'apporte ni titre ni ligne de corps :
// ce qu'il contient s'affiche comme du code, et une ligne obligatoire citée
// là n'est pas écrite.
function sectionsDuChangelog(texte) {
const sections = [];
let dansUnBloc = false;
texte.split(/\r?\n/).forEach((ligne, i) => {
if (/^\s*(```|~~~)/.test(ligne)) {
dansUnBloc = !dansUnBloc;
return;
}
if (dansUnBloc) return;
const titre = /^## (.*)$/.exec(ligne);
if (titre !== null) {
sections.push({ ligne: i + 1, titre: titre[1].trim(), corps: [] });
} else if (sections.length > 0) {
sections[sections.length - 1].corps.push(ligne);
}
});
return sections;
}
// Chemins relatifs, en « / », des fichiers d'un arbre, sans descendre dans
// node_modules ni dans un répertoire exclu. Un arbre absent n'ajoute rien.
function parcourir(racine, arbre, chemins) {
let entrees;
try {
entrees = readdirSync(join(racine, arbre), { withFileTypes: true });
} catch (erreur) {
if (erreur.code === 'ENOENT' || erreur.code === 'ENOTDIR') return;
throw erreur;
}
for (const entree of entrees) {
const chemin = `${arbre}/${entree.name}`;
if (entree.isDirectory()) {
if (entree.name !== 'node_modules' && !REPERTOIRES_EXCLUS.has(chemin)) {
parcourir(racine, chemin, chemins);
}
} else if (entree.isFile()) {
chemins.push(chemin);
}
}
}
// Fichiers de la racine, triés ; un répertoire ou un lien n'en est pas.
function fichiersDeLaRacine(racine) {
return readdirSync(racine, { withFileTypes: true })
.filter((entree) => entree.isFile())
.map((entree) => entree.name)
.sort();
}
// Fichiers du périmètre du point 6, triés.
function fichiersBalayes(racine) {
const chemins = fichiersDeLaRacine(racine);
for (const arbre of ARBRES_BALAYES) parcourir(racine, arbre, chemins);
return chemins.filter((chemin) => !FICHIERS_EXCLUS.has(chemin) && !DOCUMENT.test(chemin)).sort();
}
// Chaque occurrence du motif dans le texte : { ligne, chaine }.
function occurrences(texte, motif) {
const trouvees = [];
texte.split(/\r?\n/).forEach((ligne, i) => {
for (const [chaine] of ligne.matchAll(motif)) trouvees.push({ ligne: i + 1, chaine });
});
return trouvees;
}
// Point 1 : package.json et les deux champs du verrou portent la forme
// technique dérivée de version.json, et elle se redécompose en la version
// affichée.
function controlerRecopies(racine, { affichee, technique }, echec) {
try {
const porte = lireJson(racine, 'package.json', RAISON_PAQUET)?.version;
const retour = essayer(() => redecomposer(porte));
if (porte !== technique || retour !== affichee) {
const lue = retour === null ? '' : ` (soit ${retour})`;
echec(
1,
`package.json porte la version ${JSON.stringify(porte)}${lue}, version.json donne ${affichee}, soit ${technique} : ${CONSEIL_ENGENDRER}`,
);
}
} catch (erreur) {
echec(1, erreur.message);
}
try {
const verrou = lireJson(racine, 'package-lock.json', RAISON_VERROU);
const champs = [
['version', verrou?.version],
['packages[""].version', verrou?.packages?.['']?.version],
];
for (const [champ, porte] of champs) {
if (porte !== technique) {
echec(
1,
`package-lock.json, champ « ${champ} » : ${JSON.stringify(porte)}, version.json donne ${technique} : ${CONSEIL_ENGENDRER}`,
);
}
}
} catch (erreur) {
echec(1, erreur.message);
}
}
// Point 2 : le module engendré reproduit, caractère pour caractère, ce que
// le script engendre ; l'échec cite la première ligne qui diffère.
function controlerModule(racine, { affichee }, echec) {
const present = lireTexte(racine, MODULE);
if (present === null) {
echec(2, `${MODULE} absent : ${CONSEIL_ENGENDRER}`);
return;
}
const attendu = engendrerModule(affichee);
if (present === attendu) return;
const lignesPresentes = present.split('\n');
const lignesAttendues = attendu.split('\n');
const longueur = Math.max(lignesPresentes.length, lignesAttendues.length);
let i = 0;
while (i < longueur && lignesPresentes[i] === lignesAttendues[i]) i += 1;
const citer = (ligne) => (ligne === undefined ? 'la fin du fichier' : `« ${ligne} »`);
echec(
2,
`${MODULE}, ligne ${i + 1} : ${citer(lignesPresentes[i])} au lieu de ${citer(lignesAttendues[i])}, que le script engendre : ${CONSEIL_ENGENDRER}`,
);
}
// Point 3 : la section de tête porte la version courante, la date en lettres
// que cette version engendre, et les deux lignes obligatoires. Sans version
// valide, seules la forme du titre et les deux lignes se contrôlent.
function controlerTete(sections, version, echec) {
const tete = sections[0];
if (tete === undefined) {
echec(3, 'CHANGELOG.md : aucune section « ## » ; la section de tête porte la version courante');
return;
}
const lieu = `CHANGELOG.md, ligne ${tete.ligne}`;
const titre = /^(\S+) — (.+)$/.exec(tete.titre);
if (titre === null) {
echec(3, `${lieu} : le titre « ## ${tete.titre} » n'a pas la forme « ## <version> — <date en lettres> »`);
} else if (version !== null) {
const [, porte, date] = titre;
const attendue = dateEnLettres(version);
if (porte !== version.affichee) {
echec(
3,
`${lieu} : la section de tête porte ${porte}, version.json porte ${version.affichee} ; la section de cette version s'écrit en tête (§ 18.4)`,
);
} else if (date !== attendue) {
echec(3, `${lieu} : la date « ${date} » n'est pas « ${attendue} », que la version ${porte} engendre`);
}
}
for (const [libelle, motif] of LIGNES_OBLIGATOIRES) {
if (!tete.corps.some((ligne) => motif.test(ligne))) {
echec(3, `${lieu} : la ligne obligatoire « ${libelle} : … » manque à la section de tête (§ 18.4)`);
}
}
}
// Point 4 : chaque titre de section s'ouvre sur une version, aucune version
// n'est portée deux fois, et chaque section porte une version strictement
// antérieure à celle de la section qui la précède.
function controlerOrdre(sections, echec) {
const vues = new Map();
let precedente = null;
for (const section of sections) {
const version = essayer(() => deriver(section.titre.split(/\s/)[0]));
if (version === null) {
echec(
4,
`CHANGELOG.md, ligne ${section.ligne} : le titre « ## ${section.titre} » ne s'ouvre pas sur une version AAAA.MM.JJ.NN`,
);
continue;
}
const deja = vues.get(version.affichee);
if (deja !== undefined) {
echec(4, `CHANGELOG.md, lignes ${deja} et ${section.ligne} : deux sections portent la version ${version.affichee}`);
} else {
vues.set(version.affichee, section.ligne);
if (precedente !== null && comparerVersions(version, precedente.version) > 0) {
echec(
4,
`CHANGELOG.md, ligne ${section.ligne} : la section ${version.affichee} suit la section ${precedente.version.affichee} (ligne ${precedente.ligne}) ; les versions vont en ordre strictement décroissant`,
);
}
}
precedente = { version, ligne: section.ligne };
}
}
// Point 5 : la date de la version ne dépasse pas de plus d'un jour la date
// civile de la machine. La tolérance absorbe l'écart de fuseau.
function controlerDate(version, civile, echec) {
const avance = numeroDeJour(version) - numeroDeJour(civile);
if (avance > 1) {
echec(
5,
`version.json : la version ${version.affichee} est datée du ${dateEnLettres(version)}, ${avance} jours après la date civile de la machine, le ${dateEnLettres(civile)} ; la tolérance est d'un jour`,
);
}
}
// Point 6 : aucune chaîne de forme affichée hors de version.json, du module
// engendré et du changelog ; aucune de forme technique hors de package.json
// et du module engendré. Rend le nombre de fichiers de src/ examinés, que
// contrôle le point 8. Un fichier binaire, qui porte un octet nul, n'est pas
// examiné.
function controlerFormes(racine, echec) {
let sourcesExaminees = 0;
for (const chemin of fichiersBalayes(racine)) {
const texte = lireTexte(racine, chemin);
if (texte === null || texte.includes('\u0000')) continue;
if (chemin.startsWith(ARBRE_DES_SOURCES)) sourcesExaminees += 1;
for (const { ligne, chaine } of occurrences(texte, CHAINE_AFFICHEE)) {
echec(
6,
`${chemin}, ligne ${ligne} : « ${chaine} », forme affichée, hors de version.json, du module engendré et du changelog`,
);
}
if (chemin === 'package.json') continue;
for (const { ligne, chaine } of occurrences(texte, CHAINE_TECHNIQUE)) {
echec(6, `${chemin}, ligne ${ligne} : « ${chaine} », forme technique, hors de package.json et du module engendré`);
}
}
return sourcesExaminees;
}
// Point 7 : aucun document ne porte le littéral #_# ni ne cite un nom
// d'exécutable hors du gabarit du § 18.2. Rend le nombre de documents
// examinés, que contrôle le point 8.
function controlerDocuments(racine, echec) {
const documents = fichiersDeLaRacine(racine).filter(
(nom) => DOCUMENTS_FIXES.includes(nom) || GUIDE.test(nom),
);
for (const document of documents) {
const texte = lireTexte(racine, document);
for (const { ligne, chaine } of occurrences(texte, LITTERAL_NON_SUBSTITUE)) {
echec(7, `${document}, ligne ${ligne} : littéral « ${chaine} », un gabarit non substitué`);
}
for (const { ligne, chaine } of occurrences(texte, NOM_CITE)) {
if (!nomConforme(chaine)) {
echec(7, `${document}, ligne ${ligne} : « ${chaine} » ne se conforme pas au gabarit ${GABARIT_NOM} (§ 18.2)`);
}
}
}
return documents.length;
}
/**
* Applique à l'arbre « racine » les huit points du § 18.5, à la date civile
* « dateCivile » (AAAA-MM-JJ) de la machine qui construit. Rend la liste des
* échecs, chacun préfixé du numéro de son point (« 1. … ») et nommant
* l'endroit qui diverge ; liste vide = conforme. Lève sur une dateCivile mal
* formée.
*/
export function controler(racine, { dateCivile }) {
const civile = lireDateCivile(dateCivile);
const echecs = [];
const echec = (point, texte) => echecs.push(`${point}. ${texte}`);
let version = null;
try {
version = versionDeLaSource(racine);
} catch (erreur) {
echec(1, erreur.message);
}
if (version !== null) {
controlerRecopies(racine, version, echec);
controlerModule(racine, version, echec);
}
const changelog = lireTexte(racine, 'CHANGELOG.md');
if (changelog === null) {
echec(3, "CHANGELOG.md absent : la section de la version courante s'écrit à la livraison (§ 18.4)");
} else {
const sections = sectionsDuChangelog(changelog);
controlerTete(sections, version, echec);
controlerOrdre(sections, echec);
}
if (version !== null) controlerDate(version, civile, echec);
const sourcesExaminees = controlerFormes(racine, echec);
const documentsExamines = controlerDocuments(racine, echec);
if (sourcesExaminees === 0) {
echec(8, `le balayage du point 6 n'examine aucun fichier sous ${ARBRE_DES_SOURCES} : zéro fichier examiné n'est pas zéro problème (§ 14.2)`);
}
if (documentsExamines === 0) {
echec(8, 'le balayage du point 7 n\'examine aucun document (README.md, CHANGELOG.md, GUIDE-*.md)');
}
return echecs;
}
/**
* Date civile locale d'un instant, sous la forme AAAA-MM-JJ ; par défaut,
* celle de la machine au moment de l'appel.
*/
export function dateCivileLocale(instant = new Date()) {
return `${instant.getFullYear()}-${deuxChiffres(instant.getMonth() + 1)}-${deuxChiffres(instant.getDate())}`;
}
// --- Ligne de commande ------------------------------------------------------
const USAGE = 'usage : node scripts/version.js engendrer | controler';
// Exécute une commande sur l'arbre « racine » et rend le code de sortie : 0
// réussi, 1 échec, 2 commande inconnue.
function executer(commande, racine) {
if (commande === 'engendrer') {
try {
const reecrits = engendrer(racine);
const version = lireVersion(racine);
console.log(
reecrits.length === 0
? `Version ${version} : les recopies sont à jour.`
: `Version ${version} : réécrit ${reecrits.join(', ')}.`,
);
return 0;
} catch (erreur) {
console.error(`Version : ${erreur.message}`);
return 1;
}
}
if (commande === 'controler') {
const echecs = controler(racine, { dateCivile: dateCivileLocale() });
if (echecs.length === 0) {
console.log(`Version ${lireVersion(racine)} : conforme aux huit points du § 18.5.`);
return 0;
}
console.error(`Version : ${echecs.length} échec(s) au contrôle du § 18.5.`);
for (const echec of echecs) console.error(echec);
return 1;
}
console.error(USAGE);
return 2;
}
// Vrai quand ce fichier est le script que Node a lancé, et non un module
// importé.
function estLanceDirectement() {
if (process.argv[1] === undefined) return false;
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (estLanceDirectement()) {
process.exitCode = executer(process.argv[2], fileURLToPath(new URL('..', import.meta.url)));
}

858
scripts/version.test.js Normal file
View file

@ -0,0 +1,858 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la version datée (§ 18). Ce fichier cite des versions en clair
// par nécessité : le balayage du point 6 du § 18.5 l'exclut, et lui seul.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import {
copyFileSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, test } from '../test/lanceur.js';
import {
controler,
dateCivileLocale,
dateEnLettres,
deriver,
engendrer,
engendrerModule,
redecomposer,
} from './version.js';
const RACINE = fileURLToPath(new URL('..', import.meta.url));
const SCRIPT = fileURLToPath(new URL('./version.js', import.meta.url));
const UN_JOUR_MS = 86_400_000;
// Les jours d'une année, énumérés par le calendrier de Date.UTC : l'oracle ne
// partage rien avec la règle des mois et des années bissextiles du module
// éprouvé.
function joursDe(annee) {
const jours = [];
for (
let instant = Date.UTC(annee, 0, 1);
new Date(instant).getUTCFullYear() === annee;
instant += UN_JOUR_MS
) {
const date = new Date(instant);
jours.push({ annee, mois: date.getUTCMonth() + 1, jour: date.getUTCDate() });
}
return jours;
}
// Dernier jour du mois, lu dans le même calendrier : le jour 0 d'un mois est
// la veille de son premier jour.
function dernierJour(annee, mois) {
return new Date(Date.UTC(annee, mois, 0)).getUTCDate();
}
const deux = (n) => String(n).padStart(2, '0');
const forme = ({ annee, mois, jour }, rang) =>
`${annee}.${deux(mois)}.${deux(jour)}.${deux(rang)}`;
const champs = (technique) => technique.split('.').map(Number);
// Vrai quand a précède strictement b, comparés champ à champ comme des
// entiers.
function precede(a, b) {
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return a[i] < b[i];
}
return false;
}
const MOIS = [
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
];
describe('version : dérivation (§ 18.1, § 18.2)', () => {
test('une version affichée donne ses formes technique et Windows et le nom du livrable', () => {
assert.deepEqual(deriver('2026.10.05.01'), {
affichee: '2026.10.05.01',
technique: '2026.1005.1',
windows: '2026.1005.1.0',
nomFichier: 'gestion_table_tournante_libre_v2026_10_05_01.exe',
annee: 2026,
mois: 10,
jour: 5,
rang: 1,
});
});
test('le mois garde son zéro de tête dans la forme affichée et le perd dans la technique', () => {
assert.equal(deriver('2026.01.05.01').technique, '2026.105.1');
assert.equal(redecomposer('2026.105.1'), '2026.01.05.01');
});
test('la forme technique croît avec la date, champ à champ', () => {
const suite = ['2026.01.05.01', '2026.10.05.01', '2026.12.31.01', '2027.01.05.01'].map(
(version) => champs(deriver(version).technique),
);
assert.deepEqual(suite, [
[2026, 105, 1],
[2026, 1005, 1],
[2026, 1231, 1],
[2027, 105, 1],
]);
for (let i = 1; i < suite.length; i += 1) {
assert.ok(precede(suite[i - 1], suite[i]), `${suite[i - 1]} < ${suite[i]}`);
}
assert.ok(
precede(champs(deriver('2026.10.05.01').technique), champs(deriver('2026.10.05.02').technique)),
);
});
test('aller-retour sur chaque jour de 2026 et de 2028, sur les 99 rangs', () => {
const jours = [...joursDe(2026), ...joursDe(2028)];
assert.equal(jours.length, 365 + 366);
const affichees = [];
const noms = [];
let veille = null;
jours.forEach((date, i) => {
const rang = 1 + (i % 99);
const version = forme(date, rang);
const derivee = deriver(version);
assert.equal(derivee.technique, `${date.annee}.${date.mois * 100 + date.jour}.${rang}`);
assert.equal(derivee.windows, `${derivee.technique}.0`);
assert.equal(
derivee.nomFichier,
`gestion_table_tournante_libre_v${version.replaceAll('.', '_')}.exe`,
);
assert.equal(redecomposer(derivee.technique), version);
// Chaque champ de la ressource de version Windows tient sur 16 bits.
for (const champ of champs(derivee.windows)) assert.ok(champ <= 0xffff, derivee.windows);
const jour = champs(derivee.technique).slice(0, 2);
if (veille !== null) assert.ok(precede(veille, jour), `${veille} < ${jour}`);
veille = jour;
affichees.push(forme(date, 1));
noms.push(deriver(forme(date, 1)).nomFichier);
});
// L'ordre alphabétique des formes affichées et des noms de fichiers est
// l'ordre chronologique.
assert.deepEqual([...affichees].sort(), affichees);
assert.deepEqual([...noms].sort(), noms);
});
test('le dernier jour de chaque mois passe, le lendemain est refusé', () => {
let mois = 0;
for (const annee of [2026, 2028]) {
for (let m = 1; m <= 12; m += 1) {
const dernier = dernierJour(annee, m);
assert.doesNotThrow(() => deriver(forme({ annee, mois: m, jour: dernier }, 1)));
assert.throws(() => deriver(forme({ annee, mois: m, jour: dernier + 1 }, 1)), /jour/);
assert.throws(() => deriver(forme({ annee, mois: m, jour: 0 }, 1)), /jour/);
mois += 1;
}
}
assert.equal(mois, 24);
assert.equal(dernierJour(2026, 2), 28);
assert.equal(dernierJour(2028, 2), 29);
});
test('refuse une forme non conforme, une date invalide, un rang hors de 01 à 99', () => {
const refus = [
['2026.1.5.1', /forme/],
['2026.13.01.01', /mois/],
['2026.00.10.01', /mois/],
['2026.02.30.01', /jour/],
['2026.10.05.00', /rang/],
['2026.10.05.100', /rang.*99/],
['2026.10.05.001', /forme/], // zéro de tête : une faute de forme, pas de plafond
['2026.13.45.100', /mois/], // la date se contrôle avant le plafond du rang
['2026.10.05.1', /forme/],
['2026.10.05', /forme/],
['2026.10.05.01 ', /forme/],
['v2026.10.05.01', /forme/],
['2026-10-05-01', /forme/],
['0999.10.05.01', /année/],
];
for (const [version, motif] of refus) {
assert.throws(() => deriver(version), motif, version);
}
assert.throws(() => deriver(20261005), TypeError);
});
test('accepte le rang 99', () => {
assert.equal(deriver('2026.10.05.99').technique, '2026.1005.99');
assert.equal(redecomposer('2026.1005.99'), '2026.10.05.99');
});
test('redecomposer refuse ce qui ne désigne pas exactement une version', () => {
const refus = [
['2026.0105.1', /forme/], // zéro de tête
['2026.1005.01', /forme/], // zéro de tête
['2026.1005.0', /forme/], // rang 0
['2026.1005.100', /forme/], // rang au-delà de 99
['2026.99.1', /forme/], // mois et jour sur deux chiffres
['2026.1005', /forme/], // deux champs
['2026.10.05.01', /forme/], // forme affichée
['26.1005.1', /forme/], // année sur deux chiffres
['2026.1305.1', /mois/], // mois 13
['2026.230.1', /jour/], // 30 février
['2026.1000.1', /jour/], // jour 0
];
for (const [technique, motif] of refus) {
assert.throws(() => redecomposer(technique), motif, technique);
}
assert.throws(() => redecomposer(2026.1005), TypeError);
});
});
describe('version : date en lettres (§ 18.4)', () => {
test('écrit le jour, le mois en lettres et l’année ; le premier du mois est « 1er »', () => {
assert.equal(dateEnLettres({ annee: 2026, mois: 10, jour: 5 }), '5 octobre 2026');
assert.equal(dateEnLettres({ annee: 2027, mois: 1, jour: 1 }), '1er janvier 2027');
assert.equal(dateEnLettres({ annee: 2028, mois: 2, jour: 29 }), '29 février 2028');
});
test('nomme les douze mois', () => {
assert.deepEqual(
MOIS.map((_, i) => dateEnLettres({ annee: 2026, mois: i + 1, jour: 2 })),
MOIS.map((nom) => `2 ${nom} 2026`),
);
});
test('refuse une date hors du calendrier', () => {
assert.throws(() => dateEnLettres({ annee: 2026, mois: 2, jour: 29 }), /jour/);
assert.throws(() => dateEnLettres({ annee: 2026, mois: 13, jour: 1 }), /mois/);
});
});
// --- Arbres d'épreuve -------------------------------------------------------
const VERSION = '2026.10.05.01';
const TECHNIQUE = '2026.1005.1';
const DATE = '2026-10-05';
// Le module engendré pour VERSION, octet pour octet.
const MODULE = [
'// © 2026 TechnoLibre (http://www.technolibre.ca)',
'// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)',
'',
'// Engendré par scripts/version.js depuis version.json — ne pas modifier.',
'export const VERSION = Object.freeze({',
" affichee: '2026.10.05.01',",
" technique: '2026.1005.1',",
'});',
'',
].join('\n');
const TITRE_CHANGELOG = '# Changelog — Gestion table tournante Libre';
// Une section de changelog complète : titre, une rubrique, les deux lignes
// obligatoires.
const section = (titre) =>
[
`## ${titre}`,
'',
'**Ce qui change pour vous**',
'- Le bandeau affiche la version.',
'',
'**Version de format des fichiers** : inchangée.',
'',
'**En remplaçant** : rien à faire.',
'',
].join('\n');
const changelog = (...titres) => [TITRE_CHANGELOG, '', ...titres.map(section)].join('\n');
const enJson = (valeur) => `${JSON.stringify(valeur, null, 2)}\n`;
function ecrire(racine, chemin, contenu) {
const complet = join(racine, chemin);
mkdirSync(dirname(complet), { recursive: true });
writeFileSync(complet, contenu);
}
const lire = (racine, chemin) => readFileSync(join(racine, chemin), 'utf8');
function modifierJson(racine, chemin, modification) {
const valeur = JSON.parse(lire(racine, chemin));
modification(valeur);
ecrire(racine, chemin, enJson(valeur));
}
// Arbre conforme à VERSION et à DATE : la source, ses trois recopies, un
// changelog, un README qui cite le gabarit du nom, une source et une
// configuration à la racine.
function arbreConforme() {
const racine = mkdtempSync(join(tmpdir(), 'version-'));
ecrire(racine, 'version.json', `{ "version": "${VERSION}" }\n`);
ecrire(racine, 'package.json', enJson({ name: 'essai', version: TECHNIQUE, type: 'module' }));
ecrire(
racine,
'package-lock.json',
enJson({
name: 'essai',
version: TECHNIQUE,
lockfileVersion: 3,
requires: true,
packages: { '': { name: 'essai', version: TECHNIQUE } },
}),
);
ecrire(racine, 'src/version.genere.js', MODULE);
ecrire(
racine,
'src/main.js',
"import { VERSION } from './version.genere.js';\nexport const titre = VERSION.affichee;\n",
);
ecrire(racine, 'vite.config.js', 'export default {};\n');
ecrire(racine, 'CHANGELOG.md', changelog(`${VERSION} — 5 octobre 2026`));
ecrire(
racine,
'README.md',
"# Essai\n\nL'exécutable se nomme `gestion_table_tournante_libre_v<AAAA>_<MM>_<JJ>_<NN>.exe`.\n",
);
return racine;
}
// Exécute l'épreuve sur un arbre conforme neuf, supprimé ensuite.
function surArbre(epreuve) {
const racine = arbreConforme();
try {
return epreuve(racine);
} finally {
rmSync(racine, { recursive: true, force: true });
}
}
// Numéros de point des échecs, sans doublon, croissants. Un échec sans
// numéro fait échouer l'épreuve.
function points(echecs) {
const numeros = echecs.map((echec) => {
const numero = /^([1-8])\. /.exec(echec);
assert.ok(numero, `échec sans numéro de point : ${echec}`);
return Number(numero[1]);
});
return [...new Set(numeros)].sort((a, b) => a - b);
}
// Contrôle l'arbre et exige que les échecs nomment exactement les points
// attendus, et que chaque motif se lise dans l'un d'eux.
function exigerEchecs(racine, attendus, motifs, dateCivile = DATE) {
const echecs = controler(racine, { dateCivile });
const liste = echecs.join('\n');
assert.deepEqual(points(echecs), attendus, liste);
for (const motif of motifs) {
assert.ok(
echecs.some((echec) => motif.test(echec)),
`${motif} ne se lit dans aucun échec :\n${liste}`,
);
}
return echecs;
}
describe('version : contrôle du § 18.5, un défaut à la fois', () => {
test("l'arbre de référence est conforme", () =>
surArbre((racine) => {
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
}));
test('point 1 : package.json porte une autre version technique', () =>
surArbre((racine) => {
modifierJson(racine, 'package.json', (paquet) => {
paquet.version = '2026.1006.1';
});
exigerEchecs(racine, [1], [/^1\. package\.json .*2026\.1006\.1.*2026\.1005\.1/]);
}));
test('point 1 : le champ version du verrou porte une autre version', () =>
surArbre((racine) => {
modifierJson(racine, 'package-lock.json', (verrou) => {
verrou.version = '2026.1004.1';
});
exigerEchecs(racine, [1], [/^1\. package-lock\.json, champ « version » .*2026\.1004\.1/]);
}));
test('point 1 : le champ packages[""].version du verrou porte une autre version', () =>
surArbre((racine) => {
modifierJson(racine, 'package-lock.json', (verrou) => {
verrou.packages[''].version = '2026.1004.1';
});
exigerEchecs(
racine,
[1],
[/^1\. package-lock\.json, champ « packages\[""\]\.version » .*2026\.1004\.1/],
);
}));
test('point 1 : le verrou manque', () =>
surArbre((racine) => {
unlinkSync(join(racine, 'package-lock.json'));
exigerEchecs(racine, [1], [/^1\. package-lock\.json absent/]);
}));
test('point 1 : version.json manque', () =>
surArbre((racine) => {
unlinkSync(join(racine, 'version.json'));
exigerEchecs(racine, [1], [/^1\. version\.json absent/]);
}));
test('point 1 : version.json porte une version non conforme', () =>
surArbre((racine) => {
ecrire(racine, 'version.json', '{ "version": "2026.10.05.100" }\n');
exigerEchecs(racine, [1], [/^1\. version\.json : .*rang 100.*99/]);
}));
test('point 2 : le module engendré est modifié à la main', () =>
surArbre((racine) => {
ecrire(racine, 'src/version.genere.js', MODULE.replace("'2026.1005.1'", "'2026.1005.2'"));
exigerEchecs(racine, [2], [/^2\. src\/version\.genere\.js, ligne 7 /]);
}));
test('point 2 : le module engendré manque', () =>
surArbre((racine) => {
unlinkSync(join(racine, 'src/version.genere.js'));
exigerEchecs(racine, [2], [/^2\. src\/version\.genere\.js absent/]);
}));
test('point 3 : le titre de tête ne porte pas la version courante', () =>
surArbre((racine) => {
ecrire(racine, 'CHANGELOG.md', changelog('2026.10.04.01 — 4 octobre 2026'));
exigerEchecs(racine, [3], [/^3\. CHANGELOG\.md, ligne 3 : .*2026\.10\.04\.01.*2026\.10\.05\.01/]);
}));
test('point 3 : la date en lettres du titre de tête est fausse', () =>
surArbre((racine) => {
ecrire(racine, 'CHANGELOG.md', changelog(`${VERSION} — 6 octobre 2026`));
exigerEchecs(racine, [3], [/^3\. CHANGELOG\.md, ligne 3 : .*6 octobre 2026.*5 octobre 2026/]);
}));
test('point 3 : la ligne de la version de format manque', () =>
surArbre((racine) => {
const texte = lire(racine, 'CHANGELOG.md');
ecrire(racine, 'CHANGELOG.md', texte.replace('**Version de format des fichiers** : inchangée.\n', ''));
exigerEchecs(racine, [3], [/^3\. CHANGELOG\.md.*Version de format des fichiers/]);
}));
test('point 3 : la ligne « En remplaçant » manque', () =>
surArbre((racine) => {
const texte = lire(racine, 'CHANGELOG.md');
ecrire(racine, 'CHANGELOG.md', texte.replace('**En remplaçant** : rien à faire.\n', ''));
exigerEchecs(racine, [3], [/^3\. CHANGELOG\.md.*En remplaçant/]);
}));
test('point 3 : le changelog manque', () =>
surArbre((racine) => {
unlinkSync(join(racine, 'CHANGELOG.md'));
exigerEchecs(racine, [3], [/^3\. CHANGELOG\.md absent/]);
}));
test('point 3 : une ligne obligatoire absente de la section de tête échoue, même portée par une section plus ancienne', () => {
const lignes = [
['**Version de format des fichiers** : inchangée.\n', /^3\. CHANGELOG\.md, ligne 3 : .*Version de format des fichiers/],
['**En remplaçant** : rien à faire.\n', /^3\. CHANGELOG\.md, ligne 3 : .*En remplaçant/],
];
for (const [ligne, motif] of lignes) {
surArbre((racine) => {
const texte = changelog(`${VERSION} — 5 octobre 2026`, '2026.10.04.01 — 4 octobre 2026');
// replace ne retire que la première occurrence : celle de la section de tête.
ecrire(racine, 'CHANGELOG.md', texte.replace(ligne, ''));
exigerEchecs(racine, [3], [motif]);
});
}
});
test('point 3 : une ligne obligatoire citée dans un bloc de code ne compte pas', () =>
surArbre((racine) => {
const texte = lire(racine, 'CHANGELOG.md');
ecrire(
racine,
'CHANGELOG.md',
texte.replace('**En remplaçant** : rien à faire.\n', '```\n**En remplaçant** : rien à faire.\n```\n'),
);
exigerEchecs(racine, [3], [/^3\. CHANGELOG\.md, ligne 3 : .*En remplaçant/]);
}));
test('points 3 et 4 : une section « À venir » en tête', () =>
surArbre((racine) => {
// La section « À venir » porte ses deux lignes obligatoires : seul son
// titre fait tomber le point 3.
ecrire(racine, 'CHANGELOG.md', changelog('À venir', `${VERSION} — 5 octobre 2026`));
exigerEchecs(racine, [3, 4], [
/^3\. CHANGELOG\.md, ligne 3 : le titre « ## À venir » n'a pas la forme/,
/^4\. CHANGELOG\.md, ligne 3 : le titre « ## À venir » ne s'ouvre pas sur une version/,
]);
}));
test('point 4 : une section « À venir » entre deux versions', () =>
surArbre((racine) => {
ecrire(
racine,
'CHANGELOG.md',
changelog(`${VERSION} — 5 octobre 2026`, 'À venir', '2026.10.04.01 — 4 octobre 2026'),
);
exigerEchecs(racine, [4], [
/^4\. CHANGELOG\.md, ligne 12 : le titre « ## À venir » ne s'ouvre pas sur une version/,
]);
}));
test('points 3 et 4 : un titre « ## » cité dans un bloc de code ne découpe pas la section', () =>
surArbre((racine) => {
const texte = lire(racine, 'CHANGELOG.md');
ecrire(
racine,
'CHANGELOG.md',
texte.replace(
'- Le bandeau affiche la version.\n',
'- Le bandeau affiche la version.\n\n```markdown\n## Notes de la soirée\n```\n',
),
);
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
}));
test('points 3 et 4 : un sous-titre « ### » ne découpe pas la section', () =>
surArbre((racine) => {
const texte = lire(racine, 'CHANGELOG.md');
ecrire(
racine,
'CHANGELOG.md',
texte.replace('**Ce qui change pour vous**\n', '**Ce qui change pour vous**\n\n### Le bandeau\n'),
);
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
}));
test('point 4 : deux sections portent la même version', () =>
surArbre((racine) => {
ecrire(
racine,
'CHANGELOG.md',
changelog(`${VERSION} — 5 octobre 2026`, `${VERSION} — 5 octobre 2026`),
);
exigerEchecs(racine, [4], [/^4\. CHANGELOG\.md, lignes 3 et 12 : .*2026\.10\.05\.01/]);
}));
test('point 4 : une section plus récente suit une plus ancienne', () =>
surArbre((racine) => {
ecrire(
racine,
'CHANGELOG.md',
changelog(`${VERSION} — 5 octobre 2026`, '2026.10.06.01 — 6 octobre 2026'),
);
exigerEchecs(racine, [4], [/^4\. CHANGELOG\.md, ligne 12 : .*2026\.10\.06\.01.*2026\.10\.05\.01/]);
}));
test('point 4 : des sections en ordre strictement décroissant sont admises', () =>
surArbre((racine) => {
ecrire(
racine,
'CHANGELOG.md',
changelog(
`${VERSION} — 5 octobre 2026`,
'2026.10.04.02 — 4 octobre 2026',
'2026.10.04.01 — 4 octobre 2026',
'2025.12.31.01 — 31 décembre 2025',
),
);
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
}));
test("point 5 : la version est datée de plus d'un jour après la date civile", () =>
surArbre((racine) => {
exigerEchecs(racine, [5], [/^5\. version\.json : .*5 octobre 2026.*3 octobre 2026/], '2026-10-03');
}));
test("point 5 : un jour d'avance sur la date civile est toléré", () =>
surArbre((racine) => {
assert.deepEqual(controler(racine, { dateCivile: '2026-10-04' }), []);
assert.deepEqual(controler(racine, { dateCivile: '2027-03-01' }), []);
}));
test('point 6 : une forme affichée dans src/ hors du module engendré', () =>
surArbre((racine) => {
ecrire(racine, 'src/interface/bandeau.js', "// Bandeau.\n\nexport const v = 'v2026.10.05.01';\n");
exigerEchecs(racine, [6], [/^6\. src\/interface\/bandeau\.js, ligne 3 : « 2026\.10\.05\.01 », forme affichée/]);
}));
test('point 6 : une forme technique hors de package.json et du module engendré', () =>
surArbre((racine) => {
ecrire(racine, 'vite.config.js', "export default { define: { V: '2026.1005.1' } };\n");
ecrire(racine, 'scripts/construire.sh', '#!/bin/sh\nWINDOWS=2026.1005.1.0\n');
exigerEchecs(racine, [6], [
/^6\. vite\.config\.js, ligne 1 : « 2026\.1005\.1 », forme technique/,
/^6\. scripts\/construire\.sh, ligne 2 : « 2026\.1005\.1 », forme technique/,
]);
}));
test('point 6 : une forme affichée dans package.json', () =>
surArbre((racine) => {
modifierJson(racine, 'package.json', (paquet) => {
paquet.description = 'version 2026.10.05.01';
});
exigerEchecs(racine, [6], [/^6\. package\.json, ligne \d+ : « 2026\.10\.05\.01 », forme affichée/]);
}));
test('point 6 : les épreuves de test/ sont dans le périmètre', () =>
surArbre((racine) => {
ecrire(racine, 'test/bandeau.test.js', "export const attendu = '2026.10.05.01';\n");
exigerEchecs(racine, [6], [/^6\. test\/bandeau\.test\.js, ligne 1 /]);
}));
test('point 6 : la coquille electron/ est dans le périmètre', () =>
surArbre((racine) => {
ecrire(racine, 'electron/main.js', "const titre = 'Gestion table tournante Libre — 2026.10.05.01';\n");
exigerEchecs(racine, [6], [/^6\. electron\/main\.js, ligne 1 : « 2026\.10\.05\.01 », forme affichée/]);
}));
test('point 6 : une version ne se lit pas dans un nombre plus long qui la contient', () =>
surArbre((racine) => {
// Un chiffre ou un point devant, un chiffre derrière : chaque ligne
// prolonge une version d'un côté, sous l'une des deux formes.
ecrire(
racine,
'src/donnees.js',
[
"export const a = '12026.10.05.01';",
"export const b = '1.2026.10.05.01';",
"export const c = '2026.10.05.012';",
"export const d = '12026.1005.1';",
"export const e = '1.2026.1005.1';",
"export const f = '2026.1005.123';",
'',
].join('\n'),
);
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
}));
test("point 6 : un fichier binaire, qui porte un octet nul, n'est pas lu comme du texte", () =>
surArbre((racine) => {
ecrire(racine, 'src/interface/icone.png', `PNG\u0000${VERSION} ${TECHNIQUE}\n`);
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
}));
test('point 6 : données, guides, dépendances et sorties restent hors du périmètre', () =>
surArbre((racine) => {
const versions = `${VERSION} ${TECHNIQUE} ${TECHNIQUE}.0\n`;
for (const chemin of [
'scripts/version.test.js',
'test/fixtures/demonstration.json',
'GUIDE-USAGE.md',
'docs/notes.md',
'node_modules/paquet/index.js',
'src/node_modules/paquet/index.js',
'www/assets/index.js',
'dist/sortie.js',
'dist-electron/main.js',
'android/app/build.gradle',
]) {
ecrire(racine, chemin, versions);
}
ecrire(racine, 'README.md', `${lire(racine, 'README.md')}\nVersion ${versions}`);
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
}));
test('point 7 : un document porte le littéral #_#', () =>
surArbre((racine) => {
ecrire(racine, 'README.md', `${lire(racine, 'README.md')}\nVersion : #_#\n`);
exigerEchecs(racine, [7], [/^7\. README\.md, ligne 5 : .*#_#/]);
}));
test("point 7 : un document cite un nom d'exécutable hors du gabarit", () =>
surArbre((racine) => {
const fautifs = [
'gestion_table_tournante_libre_v2026.10.05.01.exe',
'gestion_table_tournante_libre_v2026_10_05_1.exe',
'gestion_table_tournante_libre_v2026_13_05_01.exe',
'gestion_table_tournante_libre_v<AAAA>.exe',
'gestion_table_tournante_libre.exe',
'Gestion_Table_Tournante_Libre_v2026_10_05_01.EXE',
];
ecrire(racine, 'GUIDE-WINDOWS.md', fautifs.map((nom) => `Lancer \`${nom}\`.\n`).join(''));
const echecs = exigerEchecs(
racine,
[7],
fautifs.map((nom, i) => new RegExp(`^7\\. GUIDE-WINDOWS\\.md, ligne ${i + 1} : « ${nom.replace(/[.<>]/g, '\\$&')} »`)),
);
assert.equal(echecs.length, fautifs.length, echecs.join('\n'));
}));
test('point 7 : le gabarit, un nom conforme et le préfixe seul sont admis', () =>
surArbre((racine) => {
ecrire(
racine,
'GUIDE-WINDOWS.md',
[
'Le fichier dont le nom commence par `gestion_table_tournante_libre_v`.',
'Gabarit : `gestion_table_tournante_libre_v<AAAA>_<MM>_<JJ>_<NN>.exe`.',
'Par exemple gestion_table_tournante_libre_v2026_10_05_01.exe.',
'',
].join('\n'),
);
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
}));
test('point 7 : le changelog est balayé', () =>
surArbre((racine) => {
const texte = lire(racine, 'CHANGELOG.md');
ecrire(
racine,
'CHANGELOG.md',
texte.replace(
'- Le bandeau affiche la version.\n',
'- Le bandeau affiche la version.\n- Lancer `gestion_table_tournante_libre.exe`.\n- Version #_#.\n',
),
);
const echecs = exigerEchecs(racine, [7], [
/^7\. CHANGELOG\.md, ligne 7 : « gestion_table_tournante_libre\.exe »/,
/^7\. CHANGELOG\.md, ligne 8 : littéral « #_# »/,
]);
assert.equal(echecs.length, 2, echecs.join('\n'));
}));
test('point 8 : un balayage qui ne trouve aucune source échoue', () =>
surArbre((racine) => {
rmSync(join(racine, 'src'), { recursive: true });
// Sans src/, le module engendré manque aussi : le point 2 tombe avec.
exigerEchecs(racine, [2, 8], [/^8\. .*src\//]);
}));
test('point 8 : un src/ qui ne porte que le module engendré ne compte aucune source', () =>
surArbre((racine) => {
unlinkSync(join(racine, 'src/main.js'));
const echecs = exigerEchecs(racine, [8], [/^8\. le balayage du point 6 n'examine aucun fichier sous src\//]);
assert.equal(echecs.length, 1, echecs.join('\n'));
}));
test('point 8 : un balayage qui ne trouve aucun document échoue', () =>
surArbre((racine) => {
unlinkSync(join(racine, 'README.md'));
unlinkSync(join(racine, 'CHANGELOG.md'));
// Sans changelog, le point 3 tombe avec.
exigerEchecs(racine, [3, 8], [/^8\. le balayage du point 7 n'examine aucun document/]);
}));
test("modifier version.json seul fait tomber les points 1, 2 et 3, chacun nommant l'endroit resté en arrière", () =>
surArbre((racine) => {
ecrire(racine, 'version.json', '{ "version": "2026.10.05.02" }\n');
const echecs = exigerEchecs(racine, [1, 2, 3], [
/^1\. package\.json /,
/^1\. package-lock\.json, champ « version » /,
/^1\. package-lock\.json, champ « packages\[""\]\.version » /,
/^2\. src\/version\.genere\.js, ligne 6 /,
/^3\. CHANGELOG\.md, ligne 3 : /,
]);
assert.equal(echecs.length, 5, echecs.join('\n'));
engendrer(racine);
exigerEchecs(racine, [3], [/^3\. CHANGELOG\.md, ligne 3 : /]);
const texte = lire(racine, 'CHANGELOG.md');
ecrire(
racine,
'CHANGELOG.md',
texte.replace(`${TITRE_CHANGELOG}\n\n`, `${TITRE_CHANGELOG}\n\n${section('2026.10.05.02 — 5 octobre 2026')}\n`),
);
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
}));
test('refuse une date civile mal formée', () => {
assert.throws(() => controler(RACINE, { dateCivile: '2026-10-5' }), TypeError);
assert.throws(() => controler(RACINE, { dateCivile: '2026-02-30' }), /jour/);
});
});
describe('version : engendrement', () => {
test("engendrerModule rend le module, l'en-tête de licence en tête", () => {
assert.equal(engendrerModule(VERSION), MODULE);
});
test('engendrer réécrit le module, package.json et les deux champs du verrou', () =>
surArbre((racine) => {
ecrire(racine, 'version.json', '{ "version": "2026.10.06.02" }\n');
const paquet = JSON.parse(lire(racine, 'package.json'));
const verrou = JSON.parse(lire(racine, 'package-lock.json'));
const reecrits = engendrer(racine);
assert.deepEqual(reecrits, ['src/version.genere.js', 'package.json', 'package-lock.json']);
assert.equal(lire(racine, 'src/version.genere.js'), engendrerModule('2026.10.06.02'));
assert.equal(lire(racine, 'package.json'), enJson({ ...paquet, version: '2026.1006.2' }));
verrou.version = '2026.1006.2';
verrou.packages[''].version = '2026.1006.2';
assert.equal(lire(racine, 'package-lock.json'), enJson(verrou));
}));
test('sur un arbre à jour, engendrer ne réécrit rien', () =>
surArbre((racine) => {
const fichiers = ['src/version.genere.js', 'package.json', 'package-lock.json'];
const avant = fichiers.map((chemin) => lire(racine, chemin));
assert.deepEqual(engendrer(racine), []);
assert.deepEqual(fichiers.map((chemin) => lire(racine, chemin)), avant);
}));
test('engendrer lève quand le verrou manque, sans rien écrire', () =>
surArbre((racine) => {
ecrire(racine, 'version.json', '{ "version": "2026.10.06.02" }\n');
unlinkSync(join(racine, 'package-lock.json'));
assert.throws(() => engendrer(racine), /package-lock\.json absent.*npm install/);
assert.equal(lire(racine, 'src/version.genere.js'), MODULE);
assert.equal(JSON.parse(lire(racine, 'package.json')).version, TECHNIQUE);
}));
test('engendrer refuse le centième rang de la journée, sans rien écrire', () =>
surArbre((racine) => {
ecrire(racine, 'version.json', '{ "version": "2026.10.05.100" }\n');
assert.throws(() => engendrer(racine), /^Error: version\.json : .*99/);
assert.equal(lire(racine, 'src/version.genere.js'), MODULE);
}));
});
describe('version : date civile de la machine', () => {
test('dateCivileLocale écrit la date locale sous la forme AAAA-MM-JJ', () => {
assert.equal(dateCivileLocale(new Date(2026, 9, 5, 23, 59)), '2026-10-05');
assert.equal(dateCivileLocale(new Date(2027, 0, 1, 0, 0)), '2027-01-01');
});
});
describe('version : ligne de commande', () => {
// Le script copié dans l'arbre d'épreuve prend pour racine le parent de
// son répertoire, comme dans le projet.
function lancer(racine, ...arguments_) {
const script = join(racine, 'scripts', 'version.js');
mkdirSync(dirname(script), { recursive: true });
copyFileSync(SCRIPT, script);
return spawnSync(process.execPath, [script, ...arguments_], { encoding: 'utf8' });
}
test('controler sort en 0 sur un arbre conforme, en 1 en imprimant les échecs sinon', () =>
surArbre((racine) => {
const conforme = lancer(racine, 'controler');
assert.equal(conforme.status, 0, conforme.stderr);
modifierJson(racine, 'package.json', (paquet) => {
paquet.version = '2026.1006.1';
});
const fautif = lancer(racine, 'controler');
assert.equal(fautif.status, 1, fautif.stdout);
assert.match(fautif.stderr, /^1\. package\.json /m);
}));
test('engendrer réécrit les recopies, et sort en 1 quand le verrou manque', () =>
surArbre((racine) => {
ecrire(racine, 'version.json', '{ "version": "2026.10.05.02" }\n');
const reussi = lancer(racine, 'engendrer');
assert.equal(reussi.status, 0, reussi.stderr);
assert.equal(lire(racine, 'src/version.genere.js'), engendrerModule('2026.10.05.02'));
unlinkSync(join(racine, 'package-lock.json'));
const echoue = lancer(racine, 'engendrer');
assert.equal(echoue.status, 1, echoue.stdout);
assert.match(echoue.stderr, /package-lock\.json absent/);
}));
test("une commande inconnue sort en 2 et rappelle l'usage", () =>
surArbre((racine) => {
const inconnue = lancer(racine, 'publier');
assert.equal(inconnue.status, 2);
assert.match(inconnue.stderr, /engendrer.*controler/);
}));
});
describe('version : arbre réel', () => {
test("l'arbre réel du projet est conforme, à la date civile de la machine", () => {
assert.deepEqual(controler(RACINE, { dateCivile: dateCivileLocale() }), []);
});
});

144
spec.md
View file

@ -157,8 +157,9 @@ quand la configuration en impose un. Sans ce dernier, le troisième chiffre de l
page de qualité envoie relancer vers un zéro que la configuration interdit.
**9. Générer.** *Nécessaire pour obtenir un placement, reprenable sans perte.*
La commande prend une **graine** et un **compte d'arrêt**, que chaque
proposition conserve (§ 5.7, § 8.9). Le logiciel produit plusieurs propositions
La commande prend une **graine** et un **compte d'arrêt** ; chaque proposition
conserve sa graine dérivée, ce compte et la longueur d'historique qui l'ont
produite (§ 5.7, § 8.9). Le logiciel produit plusieurs propositions
comparables ; relancer **ajoute** des candidats, un classement unique les
ordonne tous. Les réservations des étapes 6 et 7 sont honorées, le moteur place
les autres autour (§ 5.2). Le logiciel nomme automatiquement l'instant qui
@ -611,10 +612,11 @@ réglages ajoute des candidats plutôt que d'effacer les précédents, et un
classement unique les ordonne tous. Une commande efface la liste en épargnant
celui qui est retenu.
**La commande prend une graine entière et un compte d'arrêt**, que chaque
proposition conserve (§ 8.9). C'est le support de « à graine et entrée égales,
placement identique » ; sans lui, une proposition retenue cesse d'être
régénérable.
**La commande prend une graine entière et un compte d'arrêt**, et une longueur
d'historique d'acceptation dont l'algorithme fixe la valeur par défaut. Chaque
proposition conserve sa graine dérivée, ce compte et cette longueur (§ 8.9).
C'est le support de « à graine et entrée égales, placement identique » ; sans
eux, une proposition retenue cesse d'être régénérable.
**L'identifiant entier d'une proposition vient de sa place dans la suite de
graines dérivées, jamais de son ordre d'achèvement.** Quand plusieurs
@ -1264,7 +1266,7 @@ geste, jamais un minuteur » du § 8.2.
### 8.6 Les fichiers
**Emplacement.** Ces règles sont des propriétés de l'implémentation
**`electron`**, celle qui est livrée. Sous `browser`, « à côté de l'exécutable »
**`electron`**, celle qui est livrée. Sous `web`, « à côté de l'exécutable »
ne désigne rien, et la plateforme l'annonce déjà au démarrage (§ 8.8).
Le logiciel détermine son **dossier de travail une fois par séance**, par une
@ -1408,7 +1410,7 @@ croit avoir supprimé les coordonnées d'une personne les a en réalité déplac
### 8.8 Ce qui résiste à une panne
Ces garanties sont des propriétés de l'implémentation **`electron`**, celle qui
est livrée. L'implémentation `browser`, de développement, n'offre ni renommage
est livrée. L'implémentation `web`, de développement, n'offre ni renommage
atomique ni verrou, et **le logiciel l'annonce au démarrage sur cette
plateforme**.
@ -1463,7 +1465,7 @@ plateforme**.
affaiblie** : le renommage par-dessus n'y a pas la même valeur que sur le
volume système, et le support peut disparaître entre l'écriture et le
renommage. Le logiciel l'annonce comme il annonce déjà les limites de la
plateforme `browser`.
plateforme `web`.
### 8.9 La forme des placements dans le fichier
@ -1505,11 +1507,22 @@ Chaque proposition déclare, **une fois** :
- la **liste des capacités** correspondantes ;
- le **nombre de tours** ;
- l'**ensemble des identifiants de participants** qu'elle place ;
- sa **graine** entière et le **compte d'arrêt** de la recherche qui l'a produite.
- sa **graine dérivée** (§ 5.7), le **compte d'arrêt** et la **longueur de
l'historique** d'acceptation de la recherche qui l'a produite : les trois
réglages qui la déterminent.
Les deux derniers champs sont le support de « à graine et entrée égales, placement
identique » (§ 5.10) ; sans eux, cette phrase n'existe que dans un scénario de
pilotage, et une proposition retenue cesse d'être régénérable.
Ces trois champs sont le support de « à graine et entrée égales, placement
identique » (§ 5.10) : à configuration égale et pour une même version du moteur
(`produit_version`, § 8.8), la régénération d'une proposition à partir d'eux
seuls rend son plan, quel que soit son rang dans sa génération. Sans eux, cette
phrase n'existe que dans un scénario de pilotage, et une proposition retenue
cesse d'être régénérable. La longueur de l'historique a une valeur par défaut,
mais la proposition porte celle qui l'a produite : régénérer sous une valeur
supposée rendrait une autre proposition, sans le dire. Les trois champs ne
déterminent le plan que pour l'algorithme de recherche qui l'a produit, dont
l'ordre des tirages et les règles d'acceptation font partie du résultat : une
proposition produite par une autre version du moteur n'est pas garantie
régénérable.
Puis, **par tour** : la liste des tables, chacune étant la liste des
**identifiants entiers** de ses occupants **dans l'ordre des sièges** ; et la
@ -1525,7 +1538,7 @@ différents, et le § 8.8 est violé par la seule forme du stockage.
# illustration, annotée ; le fichier livré est du JSON strict
propositions: [
{ id: 3,
graine: 48271, arret: 20000,
graine: 48271, arret: 20000, historique: 1000,
tables: [7, 12, 2, ...], identifiants de table (§ 4)
capacites: [8, 8, 7, ...],
tours: 4,
@ -1850,7 +1863,7 @@ coupés trop court sont refaits à la main pendant que la file s'allonge.
> affirmer que « le logiciel ne lit pas les marges » serait faux sur la
> plateforme livrée. Ce qui manque n'est pas la maîtrise, c'est **l'épreuve** —
> elle passe par un rendu, donc par un moteur de navigateur que le cycle court
> n'a pas. La plateforme `browser`, elle, n'offre que la boîte de dialogue du
> n'a pas. La plateforme `web`, elle, n'offre que la boîte de dialogue du
> navigateur et son « ajuster à la page ».
**Ce que cette réduction retire du chemin critique.** La bibliothèque n'ingère
@ -2737,14 +2750,33 @@ nommant le critère appliqué à sa place.
### 13.1 Forme du projet
Une application **Cordova**, dont le code vit dans `www/` et ne dépend d'aucun
Une application **Capacitor**, dont le code web vit dans `www/` — la sortie de
Vite, que Capacitor désigne comme son répertoire web — et ne dépend d'aucun
service.
| plateforme | rôle |
|---|---|
| `electron` | la livraison : un exécutable Windows |
| `browser` | le développement et les tests sous Linux |
| `android` | ouvert, non requis pour la première livraison |
| plateforme | ce qui la porte | rôle |
|---|---|---|
| `electron` | une **coquille Electron propre au projet**, emballée par `electron-builder` | la livraison : un exécutable Windows |
| `web` | la plateforme web de Capacitor, servie en local | le développement et les tests sous Linux |
| `android` | la plateforme Android de Capacitor | ouverte, non requise pour la première livraison |
**Capacitor ne porte pas l'exécutable Windows, et c'est délibéré.** Il ne fournit
de plateforme de bureau que par une extension communautaire, et une plateforme
dont la mise à jour ne dépend pas du projet fige le moteur d'exécution embarqué :
l'exécutable livré porterait un Chromium que plus personne ne corrige. La coquille
du projet tient en deux fichiers — le processus principal et le script de
préchargement — et fixe elle-même sa version d'Electron, que la construction met
à jour comme n'importe quelle dépendance.
**La coquille est l'endroit où vivent les deux frontières de plateforme du § 13.4
sous `electron`** : le système de fichiers — écriture atomique, verrou, sonde
d'écriture, chemin publié par le lanceur portable (§ 8.6, § 8.8) — et
l'impression (§ 11.7). Le processus principal les exécute ; le script de
préchargement les expose à l'application par un **pont étroit et nommé**,
l'isolation de contexte restant active et l'intégration de Node coupée dans la
page. Une page qui atteindrait le système de fichiers directement ferait d'une
faille d'affichage — un nom importé interprété comme du balisage — un accès
complet au disque.
Le code que Vite compile vit sous **`src/`**, en modules nommés d'après les
couches du § 13.4 : `src/moteur`, `src/geometrie`, `src/stockage`, `src/csv`,
@ -2824,7 +2856,7 @@ message d'erreur**.
stockage lecture et écriture de fichiers
```
**Le moteur ne connaît ni le DOM ni Cordova.** Il reçoit des nombres et des
**Le moteur ne connaît ni le DOM, ni Capacitor, ni Electron.** Il reçoit des nombres et des
listes, il rend des placements. C'est ce qui le rend testable sous `node`, en
quelques secondes — et c'est la condition pour qu'il soit éprouvé sérieusement.
@ -3462,6 +3494,17 @@ couverture :
| relance sous surveillance, après une modification | **2 secondes** | le développeur commence à grouper ses modifications, et le test cesse de désigner laquelle a cassé |
| série `node` surveillée complète, à froid | **10 secondes** | le développeur change de fenêtre ; le retour de contexte coûte plus que l'épreuve, et le niveau glisse vers « avant un commit » |
**La série surveillée s'adapte au nombre de cœurs, et le budget tient sur toute
machine.** À partir de quatre cœurs, des processus complets portent la série
entière. En deçà, le lanceur n'en porterait la série que dans un seul
processus, et la relance qui suit un module de base — celui que presque toutes
les épreuves importent — dépasserait deux secondes. La série s'y joue donc en
threads sur tous les cœurs, et ses épreuves lourdes passent dans la série
`node:long`. Aucune épreuve ne disparaît : elle change de série, et ce qu'elle
éprouve ne dépend pas de la machine. Une garde refuse, dans ces séries, ce
qu'un thread refuserait et qu'un processus accepte, et le lanceur annonce à
chaque exécution le mode qu'il a retenu.
**Pourquoi ces deux valeurs.** En deçà de deux secondes, l'attention reste sur le
code et le résultat se lit comme la suite du geste. Au-delà de dix, elle part
ailleurs ; le niveau `node` cesse d'être lancé à chaque modification, et les
@ -3636,9 +3679,16 @@ certitude fausse.
gros groupe tient dans le nombre de tables » est nécessaire mais **ne suffit
pas** : trois tables de 10, 1 et 1 ne peuvent séparer quatre groupes de 3, parce
que la table de 10 exigerait 10 appartenances distinctes. Le logiciel vérifie
**deux** inégalités — plus gros groupe ≤ nombre de tables, plus grande capacité
≤ nombre de groupes — puis **construit un tour témoin**. Le générateur **refuse
une graine dont aucun tour témoin ne se construit**.
**deux** inégalités — plus gros groupe ≤ nombre de tables, plus grande
capacité − places vides ≤ nombre de groupes — puis **construit un tour
témoin**. Les places vides sont les sièges qui restent une fois chaque membre
assis, zéro quand la salle n'en laisse pas : une table reçoit au moins sa
capacité moins ces places, chacun de ses occupants d'une appartenance
différente. Dans une salle exactement pleine, la seconde inégalité se lit
« plus grande capacité ≤ nombre de groupes » ; ailleurs, cette lecture
refuserait à tort deux tables de 3 pour deux membres d'appartenances
différentes, qu'un tour sépare. Le générateur **refuse une graine dont aucun
tour témoin ne se construit**.
### 15.3 La petite démonstration — 12 membres
@ -3766,8 +3816,16 @@ ne désigne nulle part une organisation existante.
## 16. Livraison
L'exécutable Windows se construit depuis Linux par la plateforme `electron` de
Cordova, qui s'appuie sur `electron-builder`.
L'exécutable Windows se construit depuis Linux par `electron-builder`, qui
emballe la coquille Electron du projet et le même `www/` que servent les
plateformes de Capacitor (§ 13.1). La construction s'exécute dans un conteneur,
sous une image épinglée par empreinte, de sorte que la machine de développement
n'a rien à installer en dehors du projet. La cible portable n'exige pas Wine :
`electron-builder` édite lui-même les ressources de l'exécutable. L'image n'en
porte donc pas, et une cible qui l'exigerait — un installateur — y échoue au lieu
de produire le livrable que ce paragraphe exclut. Le conteneur ne reçoit aucune
variable de l'hôte : un numéro de construction d'intégration continue y
remplacerait le quatrième champ de la version Windows (§ 18.1).
**Cette capacité est à vérifier dès la première semaine**, sur un squelette vide,
au même titre que la publication du dossier d'origine par le lanceur portable
@ -3804,7 +3862,7 @@ qu'il se **garde** au lieu de se surveiller. La liste des écrans est celle du
§ 19.3.
**`GUIDE-WINDOWS.md` sort de la boucle d'engendrement** (§ 19.8) : le pilotage
s'exécute sur `browser` sous Linux, et les écrans que ce guide décrit appartiennent
s'exécute sur `web` sous Linux, et les écrans que ce guide décrit appartiennent
à `electron` sous Windows. Il **ne cite jamais un nom d'exécutable complet** : il
cite le **gabarit** et désigne « le fichier dont le nom commence par
`gestion_table_tournante_libre_v` » — un nom daté change à chaque livraison, et
@ -3916,9 +3974,10 @@ construit puis jeté sans être envoyé n'a consommé aucun numéro.
le champ détruirait la propriété de tri que toute la section achète, et cent
livraisons dans une journée désignent un autre problème que le format du numéro.
**La forme technique.** Les fichiers de description du projet et de la plateforme
exigent trois champs entiers **sans zéro de tête** : le format qu'ils valident
refuse `0105`. Le logiciel dérive donc le mois et le jour en **un entier**,
**La forme technique.** Le fichier de description du projet, `package.json`,
exige trois champs entiers **sans zéro de tête** — le format de version qu'il
valide refuse `0105` — et `electron-builder` en tire la ressource de version de
l'exécutable Windows. Le logiciel dérive donc le mois et le jour en **un entier**,
`MM × 100 + JJ`, écrit sans remplissage, et le rang en entier :
```
@ -3935,6 +3994,11 @@ nombres tiennent dans les **16 bits** que le format de ressources Windows impose
chacun de ses quatre champs : l'année, `MM × 100 + JJ` qui vaut au plus 1231, et
le rang.
**Quand la plateforme `android` sera ajoutée**, son `versionCode`, un entier
unique, dérivera de la même source sous la forme `AAMMJJNN` sur huit chiffres —
`26100501` —, monotone et sous la borne de 2 100 000 000 qu'impose la
plateforme, et rejoindra le contrôle du § 18.5.
**La longueur fixe ne vaut que pour la forme affichée et pour le nom de fichier**,
qui sont les seules qu'un humain trie. La forme technique n'est jamais lue par
l'opérateur et n'est jamais triée.
@ -3977,8 +4041,8 @@ Tout le reste en dérive, par un script de construction :
| destination | forme | usage |
|---|---|---|
| les fichiers de description du projet et de la plateforme | technique | ce qu'exigent les outils de construction |
| nom de l'exécutable | soulignés | ce que l'opérateur voit dans son dossier |
| `package.json` | technique | ce qu'exigent npm et `electron-builder`, qui en tire la ressource de version Windows |
| nom de l'exécutable, posé dans la configuration d'`electron-builder` | soulignés | ce que l'opérateur voit dans son dossier |
| `src/version.genere.js` | affichée **et** technique | ce que l'application affiche |
| titre de la section de tête du `CHANGELOG.md` | affichée | ce que l'opérateur lit avant de remplacer |
@ -4058,7 +4122,7 @@ est une livraison dont l'opérateur ne peut pas décider.
Chaque endroit où un chiffre se recopie est une occasion de diverger. Un contrôle
de la série `node` surveillée (§ 14.4) **échoue** quand :
1. la version technique des fichiers de description n'est pas celle que la
1. la version technique de `package.json` n'est pas celle que la
dérivation du § 18.1 produit depuis `version.json`, ou ne se redécompose pas en
la version affichée ;
2. le module engendré ne reproduit pas, caractère pour caractère, ce que le script
@ -4073,7 +4137,7 @@ de la série `node` surveillée (§ 14.4) **échoue** quand :
contrôle attrape est une année ou un mois tapé de travers, pas une minute ;
6. une chaîne **de la forme affichée** apparaît ailleurs que dans `version.json`,
le module engendré et le changelog, ou une chaîne **de la forme technique**
ailleurs que dans les fichiers de description et le module engendré ;
ailleurs que dans `package.json` et le module engendré ;
7. un document construit porte le littéral `#_#`, ou cite un nom d'exécutable qui
**ne se conforme pas au gabarit** du § 18.2. Un gabarit non substitué se lit
comme un nom de fichier et envoie l'opérateur chercher un fichier qui n'existe
@ -4130,7 +4194,7 @@ question. Le logiciel fait donc en sorte qu'on n'ait pas à la chercher.
complément.** Un signalement arrive le plus souvent sous forme d'image, et une
capture faite à la touche d'impression d'écran, ou cadrée sur le défaut, ne
contient pas toujours la barre de titre du système — elle ne la contient jamais sur
la plateforme `browser`, où le titre est celui d'un onglet. Un bandeau dessiné par
la plateforme `web`, où le titre est celui d'un onglet. Un bandeau dessiné par
l'application est dans l'image quel que soit le cadrage. Le § 8.4 impose en plus un
cadre permanent **autour du plan** pour le mode : les deux coexistent, le cadre
signalant le mode là où la main agit, le bandeau portant la version jusque sur la
@ -4194,7 +4258,7 @@ documentation s'arrête en nommant l'étape, et **elle ne republie pas les image
passage précédent** : une documentation partielle qui se complète avec d'anciennes
images est exactement le défaut que la boucle existe pour supprimer.
Le pilotage s'exécute sur la plateforme `browser` (§ 13.1), servie en local. Le
Le pilotage s'exécute sur la plateforme `web` (§ 13.1), servie en local. Le
pilote, le navigateur et son pilote de protocole sont des **dépendances de
développement** : l'exigence « aucune connexion requise » du § 2 porte sur le
logiciel livré, jamais sur l'atelier qui le construit.
@ -4216,7 +4280,7 @@ se juge sur elles, pas sur le nom :
atteignable.
2. **La capture d'écran d'un élément**, et non de la fenêtre. Elle **recadre sur la
surface de l'application** : le cadre de fenêtre et la barre système — ce qui
diffère entre `browser` sous Linux et `electron` sous Windows — sortent de
diffère entre `web` sous Linux et `electron` sous Windows — sortent de
l'image, et le guide d'usage reste vrai sur la plateforme livrée.
3. **La maîtrise de la taille du cadre d'affichage.** Voir § 19.4 : la commande du
standard dimensionne la **fenêtre**, pas le cadre d'affichage, et le logiciel ne
@ -4491,7 +4555,7 @@ région engendrée fait échouer la construction au lieu d'être perdue au passa
suivant (§ 14.3).
**`GUIDE-WINDOWS.md` sort de la boucle** (§ 16.1). Le pilotage s'exécute sur
`browser` sous Linux ; les écrans que ce guide décrit — obtenir l'exécutable, le
`web` sous Linux ; les écrans que ce guide décrit — obtenir l'exécutable, le
premier lancement, le blocage par un antivirus, l'emplacement des fichiers dans
l'explorateur — appartiennent à `electron` sous Windows et **aucun scénario ne les
produit**. Le guide les décrit en toutes lettres, ou les illustre par des images
@ -4648,7 +4712,7 @@ avec elle donne une couverture imaginaire.
`touch-action`, qui ne se juge que sous un vrai doigt. Ce sont les pannes que le
§ 7.3 nomme, et elles restent au niveau manuel.
- **les garanties de panne du § 8.8.** Elles sont des propriétés de
l'implémentation `electron` ; le pilotage s'exécute sur `browser`. **Aucune
l'implémentation `electron` ; le pilotage s'exécute sur `web`. **Aucune
capture d'écran n'établit quoi que ce soit sur la résistance à une panne.**
- **la lisibilité.** Une image nette d'un plan illisible est une image nette.
- **la justesse du français**, et le fait qu'une phrase du guide dise bien ce que

124
src/demo/appartenances.js Normal file
View file

@ -0,0 +1,124 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Appartenances d'une démonstration tirée (§ 15.2) : la loi des tailles de
// groupe, et la faisabilité d'un tour qui sépare toutes les appartenances.
// Les fonctions reçoivent des tailles de groupe et des capacités de table,
// jamais des personnes : un membre se désigne par son rang dans la suite des
// groupes, les membres du premier groupe d'abord, puis ceux du deuxième, et
// ainsi de suite. Une table sépare les appartenances quand elle ne reçoit
// jamais deux membres d'un même groupe. Aucune fonction ne modifie les listes
// qu'elle reçoit ; tirerTailles fait avancer le générateur qu'on lui passe.
/** Poids des tailles de groupe 1 à 12 (§ 15.2) : POIDS_TAILLES[s − 1] pèse la taille s. */
export const POIDS_TAILLES = Object.freeze([24, 20, 16, 8, 6, 5, 4, 3, 2, 2, 1, 1]);
/**
* Somme des nombres d'une liste ; 0 pour une liste vide.
* @param {number[]} liste
* @returns {number}
*/
export const somme = (liste) => liste.reduce((a, b) => a + b, 0);
const maximum = (liste) => liste.reduce((a, b) => Math.max(a, b), 0);
const SOMME_POIDS = somme(POIDS_TAILLES);
// Une taille tirée selon POIDS_TAILLES, par un seul borne(92) : le tirage u
// donne la taille s quand la somme des s − 1 premiers poids est ≤ u et que
// celle des s premiers le dépasse.
function tirerTaille(rng) {
let reste = rng.borne(SOMME_POIDS);
let rang = 0;
while (reste >= POIDS_TAILLES[rang]) {
reste -= POIDS_TAILLES[rang];
rang += 1;
}
return rang + 1;
}
/**
* Tailles de groupe tirées pour total membres (§ 15.2) : une taille tirée par
* groupe, tant que la somme des tailles reste sous total, puis le dernier
* groupe tronqué au reste. La somme rendue vaut total ; total = 0 ne tire rien
* et rend [].
*
* @param {{borne: function(number): number}} rng générateur de prng.js
* @param {number} total membres à répartir, entier ≥ 0
* @returns {number[]} tailles, de 1 à 12, dans l'ordre des tirages
* @throws {RangeError} total n'est pas un entier ≥ 0
*/
export function tirerTailles(rng, total) {
if (!Number.isInteger(total) || total < 0) {
throw new RangeError(`total : entier ≥ 0 attendu, reçu ${String(total)}`);
}
const tailles = [];
let couverts = 0;
while (couverts < total) {
const taille = Math.min(tirerTaille(rng), total - couverts);
tailles.push(taille);
couverts += taille;
}
return tailles;
}
/**
* Les deux inégalités qu'un tour séparant toutes les appartenances exige
* (§ 15.2), contrôlées dans cet ordre ; la première rompue est nommée.
* - PLUS_GROS_GROUPE : le plus gros groupe dépasse le nombre de tables, alors
* que chacun de ses membres occupe une table différente.
* - PLUS_GRANDE_CAPACITE : la plus grande capacité, diminuée des places
* vides, dépasse le nombre de groupes. Les places vides sont les sièges qui
* restent une fois chaque membre assis, zéro quand la salle n'en laisse
* pas. Une table reçoit au moins sa capacité moins ces places, chacun de
* ses membres d'un groupe différent. Dans une salle exactement pleine,
* c'est « plus grande capacité ≤ nombre de groupes ».
* Nécessaires, elles ne suffisent pas : seul tourTemoin conclut.
*
* @param {number[]} tailles taille de chaque groupe
* @param {number[]} capacites sièges de chaque table
* @returns {{ok: boolean, raison?: 'PLUS_GROS_GROUPE'|'PLUS_GRANDE_CAPACITE'}}
*/
export function faisabilite(tailles, capacites) {
if (maximum(tailles) > capacites.length) return { ok: false, raison: 'PLUS_GROS_GROUPE' };
const placesVides = Math.max(0, somme(capacites) - somme(tailles));
if (maximum(capacites) - placesVides > tailles.length) {
return { ok: false, raison: 'PLUS_GRANDE_CAPACITE' };
}
return { ok: true };
}
/**
* Un tour témoin (§ 15.2) : chaque membre assis, aucune table au-delà de sa
* capacité, aucun groupe deux fois à une même table.
*
* Les groupes se placent dans l'ordre reçu, chacun aux tables qui gardent le
* plus de sièges libres, à égalité la table de plus petit index, ses membres
* dans l'ordre de ces tables. Ce choix glouton ne manque aucun tour qui
* existe. Soit un tour qui place le groupe à une table b plutôt qu'à une
* table a du choix, laquelle gardait au moins autant de sièges libres : ou a
* garde un siège dans ce tour, et le membre passe de b à a ; ou a est pleine,
* elle reçoit alors plus de membres des groupes suivants que b, et l'un
* d'eux, assis en a et non en b, échange sa place avec lui. Répété, l'échange
* donne un tour qui place le groupe exactement comme le choix glouton : les
* groupes suivants ont un tour sur les sièges que ce choix leur laisse.
*
* @param {number[]} tailles taille de chaque groupe
* @param {number[]} capacites sièges de chaque table
* @returns {number[]|null} index de table de chaque membre, dans l'ordre des
* membres ; null quand aucun tour ne sépare tous les groupes
*/
export function tourTemoin(tailles, capacites) {
const libres = [...capacites];
const ordre = capacites.map((_, t) => t);
const tour = [];
for (const taille of tailles) {
ordre.sort((a, b) => libres[b] - libres[a] || a - b);
if (taille > ordre.length || libres[ordre[taille - 1]] === 0) return null;
for (let m = 0; m < taille; m += 1) {
const t = ordre[m];
tour.push(t);
libres[t] -= 1;
}
}
return tour;
}

View file

@ -0,0 +1,269 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des appartenances tirées (§ 15.2) : la loi des tailles, la
// troncature du dernier groupe, les deux inégalités et le tour témoin. Les
// bornes de la loi sont calculées à la main depuis le tableau des poids du
// § 15.2 ; le tour témoin se compare à une recherche exhaustive sur toutes les
// petites instances d'un domaine fixé.
import assert from 'node:assert/strict';
import { isDeepStrictEqual } from 'node:util';
import { describe, test } from '../../test/lanceur.js';
import { POIDS_TAILLES, faisabilite, tirerTailles, tourTemoin } from './appartenances.js';
import { FLUX, creerPcg32 } from './prng.js';
// Générateur scripté : borne(n) rend les valeurs données, dans l'ordre, et
// note chaque n reçu. Un tirage de plus que prévu lève.
function scripte(valeurs) {
const recus = [];
return {
recus,
borne(n) {
recus.push(n);
if (recus.length > valeurs.length) throw new Error(`tirage ${recus.length} non prévu`);
return valeurs[recus.length - 1];
},
};
}
const somme = (liste) => liste.reduce((a, b) => a + b, 0);
// Écarts d'un tour à la définition du tour témoin, une ligne chacun : un index
// de table par membre, les membres numérotés groupe après groupe ; aucune
// table au-delà de sa capacité ; aucun groupe deux fois à une même table.
function ecartsDuTour(tailles, capacites, tour) {
if (!Array.isArray(tour) || tour.length !== somme(tailles)) {
return [`${tour?.length} index de table pour ${somme(tailles)} membres`];
}
const ecarts = [];
const occupation = capacites.map(() => 0);
let membre = 0;
for (let g = 0; g < tailles.length; g += 1) {
const tablesDuGroupe = new Set();
for (let m = 0; m < tailles[g]; m += 1, membre += 1) {
const t = tour[membre];
if (!Number.isInteger(t) || t < 0 || t >= capacites.length) {
ecarts.push(`membre ${membre} : table ${t}`);
continue;
}
if (tablesDuGroupe.has(t)) ecarts.push(`groupe ${g} : deux membres à la table ${t}`);
tablesDuGroupe.add(t);
occupation[t] += 1;
}
}
for (let t = 0; t < capacites.length; t += 1) {
if (occupation[t] > capacites[t]) {
ecarts.push(`table ${t} : ${occupation[t]} membres pour ${capacites[t]} sièges`);
}
}
return ecarts;
}
// Vrai quand un tour sépare tous les groupes : chaque groupe essaie toutes les
// combinaisons de tables distinctes qui ont encore un siège.
function existeTour(tailles, capacites) {
const libres = [...capacites];
const placer = (g) => {
if (g === tailles.length) return true;
const choisir = (depuis, restants) => {
if (restants === 0) return placer(g + 1);
for (let t = depuis; t < libres.length; t += 1) {
if (libres[t] === 0) continue;
libres[t] -= 1;
const trouve = choisir(t + 1, restants - 1);
libres[t] += 1;
if (trouve) return true;
}
return false;
};
return choisir(0, tailles[g]);
};
return placer(0);
}
// Suites de longueur 1 à longueurMax, de valeurs 1 à valeurMax.
function suites(longueurMax, valeurMax) {
const toutes = [];
const etendre = (prefixe) => {
if (prefixe.length > 0) toutes.push(prefixe);
if (prefixe.length === longueurMax) return;
for (let v = 1; v <= valeurMax; v += 1) etendre([...prefixe, v]);
};
etendre([]);
return toutes;
}
describe('appartenances : la loi des tailles', () => {
test('les poids du § 15.2, tailles 1 à 12, somme 92', () => {
assert.deepEqual(POIDS_TAILLES, [24, 20, 16, 8, 6, 5, 4, 3, 2, 2, 1, 1]);
assert.ok(Object.isFrozen(POIDS_TAILLES));
});
test('un borne(92) par groupe, lu sur les poids cumulés, à chaque frontière', () => {
// Poids cumulés 24, 44, 60, 68, 74, 79, 83, 86, 88, 90, 91, 92 : la
// taille s couvre les tirages de la somme des s − 1 premiers poids à celle
// des s premiers, moins un. Chaque taille est tirée à ses deux bords.
const valeurs = [0, 23, 24, 43, 44, 59, 60, 67, 68, 73, 74, 78, 79, 82, 83, 85, 86, 87, 88, 89, 90, 91];
const tailles = [1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 12];
const rng = scripte(valeurs);
assert.deepEqual(tirerTailles(rng, somme(tailles)), tailles);
assert.deepEqual(rng.recus, valeurs.map(() => 92));
});
test('le tirage s’arrête quand la somme atteint le total, le dernier groupe tronqué au reste', () => {
// [valeurs scriptées, total, tailles attendues]
const cas = [
[[91, 91, 91], 30, [12, 12, 6]],
[[91], 5, [5]],
[[0], 1, [1]],
[[24, 0], 3, [2, 1]],
[[], 0, []],
];
const ecarts = cas
.map(([valeurs, total, attendu]) => {
const rng = scripte(valeurs);
const obtenu = tirerTailles(rng, total);
return { total, attendu, obtenu, tirages: rng.recus.length, prevus: valeurs.length };
})
.filter(({ attendu, obtenu, tirages, prevus }) => !isDeepStrictEqual(obtenu, attendu) || tirages !== prevus)
.map(({ total, attendu, obtenu, tirages }) =>
`total ${total} : ${JSON.stringify(obtenu)} en ${tirages} tirages, ${JSON.stringify(attendu)} attendu`);
assert.deepEqual(ecarts, []);
});
test('sous PCG32, les tailles couvrent 260 membres, chacune de 1 à 12', () => {
const ecarts = [];
for (let graine = 0; graine < 100; graine += 1) {
const tailles = tirerTailles(creerPcg32(graine, FLUX.DEMO), 260);
if (somme(tailles) !== 260) ecarts.push(`graine ${graine} : somme ${somme(tailles)}`);
if (tailles.some((s) => !Number.isInteger(s) || s < 1 || s > 12)) {
ecarts.push(`graine ${graine} : ${JSON.stringify(tailles)}`);
}
}
assert.deepEqual(ecarts, []);
});
test('un total qui n’est pas un entier ≥ 0 lève RangeError', () => {
for (const total of [-1, 2.5, Number.NaN, '260', undefined]) {
assert.throws(() => tirerTailles(scripte([]), total), RangeError, String(total));
}
});
});
describe('appartenances : les deux inégalités', () => {
// [tailles, capacités, verdict attendu, ce que le cas exerce]
const CAS = [
[[3, 3, 3, 3], [10, 1, 1], { ok: false, raison: 'PLUS_GRANDE_CAPACITE' },
'§ 15.2 : la table de 10 exigerait 10 appartenances distinctes'],
[[2, 2], [3, 1], { ok: false, raison: 'PLUS_GRANDE_CAPACITE' },
'salle pleine : une table de 3 pour 2 groupes'],
[[2, 2, 2], [5, 2], { ok: false, raison: 'PLUS_GRANDE_CAPACITE' },
'une place vide : la table de 5 reçoit au moins 4 membres, pour 3 groupes'],
[[4, 2], [3, 3, 3], { ok: false, raison: 'PLUS_GROS_GROUPE' }, 'un groupe de 4 pour 3 tables'],
[[3, 2, 1], [3, 3], { ok: false, raison: 'PLUS_GROS_GROUPE' }, 'un groupe de 3 pour 2 tables'],
[[3, 3, 3, 3], [10, 1], { ok: false, raison: 'PLUS_GROS_GROUPE' },
'les deux rompues : la première est nommée'],
[[2, 2, 1], [3, 2], { ok: true }, 'les deux inégalités à égalité'],
[[2, 2, 2], [4, 3], { ok: true },
'une place vide : la table de 4 reçoit au moins 3 membres, pour 3 groupes'],
[[1, 1, 1], [8, 2], { ok: true }, 'sept places vides'],
[[2, 2], [2, 1], { ok: true },
'salle trop petite : aucune place vide, la table de 2 tient dans 2 groupes'],
[[2, 2, 2], [4, 1], { ok: false, raison: 'PLUS_GRANDE_CAPACITE' },
'salle trop petite : aucune place vide, la table de 4 dépasse 3 groupes'],
[[3, 3, 3, 1, 1, 1], [6, 4, 2], { ok: true }, 'inégalités tenues, sans tour pourtant'],
[[], [8], { ok: true }, 'aucun membre'],
];
test('chaque cas reçoit son verdict', () => {
const ecarts = CAS
.map(([tailles, capacites, attendu, quoi]) => ({ quoi, attendu, obtenu: faisabilite(tailles, capacites) }))
.filter(({ attendu, obtenu }) => !isDeepStrictEqual(obtenu, attendu))
.map(({ quoi, attendu, obtenu }) =>
`${quoi} : ${JSON.stringify(obtenu)}, ${JSON.stringify(attendu)} attendu`);
assert.deepEqual(ecarts, []);
});
});
describe('appartenances : le tour témoin', () => {
test('un tour qui sépare chaque groupe quand il en existe un', () => {
// [tailles, capacités], chacun admettant un tour
const cas = [
[[2, 2, 1], [3, 2]],
[[2, 2, 2], [4, 3]],
[[1, 1, 1], [8, 2]],
[[4, 4, 4], [3, 3, 3, 3]],
[[5, 3, 1, 1], [3, 3, 2, 2, 2]],
];
const ecarts = cas.flatMap(([tailles, capacites]) => {
const tour = tourTemoin(Object.freeze([...tailles]), Object.freeze([...capacites]));
const quoi = `${JSON.stringify(tailles)} / ${JSON.stringify(capacites)}`;
if (tour === null) return [`${quoi} : null`];
return ecartsDuTour(tailles, capacites, tour).map((e) => `${quoi} : ${e}`);
});
assert.deepEqual(ecarts, []);
});
test('null quand aucun tour ne sépare tous les groupes', () => {
const cas = [
[[3, 3, 3, 3], [10, 1, 1]],
[[3, 3, 3, 1, 1, 1], [6, 4, 2]],
[[3, 3, 1, 1], [4, 3, 1]],
[[1, 1], [1]],
[[2], []],
];
const rendus = cas.filter(([tailles, capacites]) => tourTemoin(tailles, capacites) !== null);
assert.deepEqual(rendus, []);
});
test('aucun membre : un tour vide', () => {
assert.deepEqual(tourTemoin([], [8]), []);
});
test('chaque groupe prend les tables aux sièges libres les plus nombreux, à égalité la plus petite', () => {
// Deux tables de 2 : le premier membre prend la table 0 ; le second, la
// table 1, qui garde alors plus de sièges libres.
assert.deepEqual(tourTemoin([1, 1], [2, 2]), [0, 1]);
// Les tables 1 et 2 gardent 2 sièges, la table 0 un seul : les membres
// du groupe suivent l'ordre des tables choisies.
assert.deepEqual(tourTemoin([2], [1, 2, 2]), [1, 2]);
});
// Sur chaque instance du domaine : tourTemoin rend un tour valide quand la
// recherche exhaustive en trouve un, null sinon ; faisabilite n'en refuse
// aucune qui a un tour.
test('petites instances : un tour quand, et seulement quand, la recherche exhaustive en trouve un', () => {
// Domaine : 1 à 4 groupes de 1 à 4 membres, 1 à 4 tables de 1 à 4 sièges,
// soit 340 × 340 instances. Il contient des instances sans tour où les
// deux inégalités tiennent, et des instances à places vides dont la
// plus grande table dépasse le nombre de groupes et qui ont un tour.
const ecarts = [];
let avecTour = 0;
let sansTourInegalitesTenues = 0;
let avecTourTableAuDelaDesGroupes = 0;
for (const tailles of suites(4, 4)) {
for (const capacites of suites(4, 4)) {
const quoi = () => `${JSON.stringify(tailles)} / ${JSON.stringify(capacites)}`;
const existe = existeTour(tailles, capacites);
const tour = tourTemoin(tailles, capacites);
const verdict = faisabilite(tailles, capacites);
if (existe) avecTour += 1;
if (!existe && verdict.ok) sansTourInegalitesTenues += 1;
if (existe && Math.max(...capacites) > tailles.length) avecTourTableAuDelaDesGroupes += 1;
if ((tour !== null) !== existe) {
const recherche = existe ? 'en trouve un' : 'n’en trouve aucun';
ecarts.push(`${quoi()} : ${tour === null ? 'null' : 'un tour'}, la recherche ${recherche}`);
} else if (tour !== null) {
ecarts.push(...ecartsDuTour(tailles, capacites, tour).map((e) => `${quoi()} : ${e}`));
}
if (existe && !verdict.ok) {
ecarts.push(`${quoi()} : faisabilite refuse (${verdict.raison}) une instance qui a un tour`);
}
}
}
assert.deepEqual(ecarts.slice(0, 12), []);
assert.ok(avecTour > 0, 'aucune instance avec tour');
assert.ok(sansTourInegalitesTenues > 0, 'aucune instance où seul le tour témoin conclut');
assert.ok(avecTourTableAuDelaDesGroupes > 0, 'aucune instance où les places vides comptent');
});
});

269
src/demo/catalogue.js Normal file
View file

@ -0,0 +1,269 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les quatre démonstrations livrées (§ 15) et le générateur des démonstrations
// tirées. Une démonstration est une configuration (types.js) tirée d'une
// graine écrite dans sa définition (§ 15.5, point 2) : à graine égale,
// construire() rend la même configuration, faite d'objets neufs à chaque
// appel. Le catalogue n'essaie jamais d'autre graine que la sienne.
//
// Chaque construction tire d'un seul générateur, creerPcg32(graine,
// FLUX.DEMO), dans un ordre qui fait partie du contrat (§ 15.5, point 3) :
// insérer, retirer ou déplacer un tirage change tout ce qui le suit. Un
// tirage sans remise de k éléments d'un réservoir de n est le mélange partiel
// de Fisher et Yates sur une copie du réservoir : pour i de 0 à k − 1,
// l'élément i échange sa place avec l'élément i + borne(n − i) ; les k
// premiers sont rendus dans cet ordre.
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { faisabilite, somme, tirerTailles, tourTemoin } from './appartenances.js';
import { NOMS_LONGS, PRENOMS, organisations, patronymes } from './noms.js';
import { FLUX, creerPcg32 } from './prng.js';
// Graine de la grande démonstration, partagée par sa variante.
const GRAINE_GRANDE = 1;
// Graine de la petite démonstration, partagée par sa variante.
const GRAINE_PETITE = 2;
// 33 tables : les 29 premières de 8 sièges, les tables 30 à 33 de 7 (§ 15.1).
// La variante : 33 tables de 8. Les deux tirent 260 membres sur 4 tours.
const CAPACITES_GRANDE = Object.freeze([...Array(29).fill(8), ...Array(4).fill(7)]);
const CAPACITES_SANS_EXCEPTION = Object.freeze(Array(33).fill(8));
const DEFINITION_GRANDE = Object.freeze({ effectif: 260, capacites: CAPACITES_GRANDE, tours: 4 });
const DEFINITION_SANS_EXCEPTION = Object.freeze({
effectif: 260,
capacites: CAPACITES_SANS_EXCEPTION,
tours: 4,
});
// Appartenances A, B et C de la petite démonstration (§ 15.3), puis celles de
// la variante, où la personne 10 passe de C à A.
const GROUPES_PETITE = [[1, 5, 8, 12], [2, 4, 9, 11], [3, 6, 7, 10]];
const GROUPES_PETITE_CONFLIT = [[1, 5, 8, 10, 12], [2, 4, 9, 11], [3, 6, 7]];
const PETITE = { membres: 12, tables: 4, capacite: 3, tours: 4 };
// Les personnes qui portent NOMS_LONGS, par identifiant (§ 15.4).
const NOM_LONG_DE = new Map([[7, NOMS_LONGS[0]], [77, NOMS_LONGS[1]], [177, NOMS_LONGS[2]]]);
// Identifiants de 1 à n, croissants.
const idsJusqua = (n) => Array.from({ length: n }, (_, rang) => rang + 1);
// Les quatre contraintes, toutes actives : un objet neuf à chaque appel.
function contraintesActives() {
return {
separerAppartenances: true,
nouveauxVoisins: true,
nouvelleTable: true,
varierAppartenances: true,
};
}
// k éléments tirés sans remise, par le mélange partiel décrit en tête : un
// borne() par élément tiré.
function tirerSansRemise(rng, reservoir, k) {
const pile = [...reservoir];
for (let i = 0; i < k; i += 1) {
const j = i + rng.borne(pile.length - i);
const element = pile[j];
pile[j] = pile[i];
pile[i] = element;
}
return pile.slice(0, k);
}
// Un prénom par personne, avec remise, dans l'ordre des ids : un borne()
// chacun.
function tirerPrenoms(rng, effectif) {
return Array.from({ length: effectif }, () => PRENOMS[rng.borne(PRENOMS.length)]);
}
// Participants d'ids 1 à N, à partir des listes tirées, indexées par
// id − 1.
function participantsDe(noms, prenoms, appartenances) {
return noms.map((nom, rang) => ({
id: rang + 1,
nom,
prenom: prenoms[rang],
appartenance: appartenances[rang],
}));
}
// Tables d'ids et de numéros 1 à T, dans l'ordre des capacités.
function tablesDe(capacites) {
return capacites.map((capacite, rang) => ({ id: rang + 1, numero: rang + 1, capacite }));
}
function exigerReservoir(reservoir, demandes, disponibles) {
if (demandes > disponibles) {
throw new ErreurConfiguration('DEMO_RESERVOIR', { reservoir, demandes, disponibles });
}
}
// Refuse des tailles de groupe fixées dont l'une n'est pas un entier ≥ 1 ;
// l'erreur nomme la première par son rang.
function exigerTailles(tailles) {
const rang = tailles.findIndex((taille) => !Number.isInteger(taille) || taille < 1);
if (rang !== -1) {
throw new RangeError(`tailles[${rang}] : entier ≥ 1 attendu, reçu ${String(tailles[rang])}`);
}
}
/**
* Démonstration tirée (§ 15.1, § 15.2) : des groupes de taille tirée, une
* personne par membre, et un animateur ancré à chaque table.
*
* Tirages, dans l'ordre du contrat :
* 1. les tailles de groupe, tirerTailles(rng, effectif), sauf quand la
* définition les fixe ;
* 2. un patronyme par personne, sans remise ; les ids 7, 77 et 177 portent
* ensuite NOMS_LONGS, dans cet ordre, à la place du leur, sans autre
* tirage ;
* 3. un prénom par personne, ids croissants, un borne() chacun ;
* 4. un libellé d'organisation par groupe, sans remise, dans l'ordre des
* groupes ;
* 5. un mélange des ids 1 à N : ses tailles[0] premiers forment le premier
* groupe, les suivants le deuxième, et ainsi de suite ;
* 6. un mélange des ids 1 à N : ses T premiers sont les animateurs, le rang
* i réservé à la table i + 1 pour tous les tours, sans égard à son
* appartenance (§ 15.1).
*
* Après le tirage 1, et avant tout autre, la définition est refusée par
* ErreurConfiguration, contrôlée dans cet ordre : DEMO_SANS_PARTICIPANT
* (aucun membre), DEMO_SANS_TABLE (aucune table), DEMO_RESERVOIR (plus de
* personnes que de patronymes, ou plus de groupes que d'organisations ;
* détails reservoir, demandes, disponibles), DEMO_SANS_TOUR_TEMOIN (aucun
* tour ne sépare toutes les appartenances ; détails graine et raison : la
* première inégalité rompue de faisabilite, ou CONSTRUCTION quand les deux
* tiennent et que tourTemoin ne trouve aucun tour). Le tour témoin porte sur
* les tailles et les capacités ; les animateurs n'y entrent pas.
*
* @param {Object} definition
* @param {number} definition.graine entier de 0 à 2^32 − 1
* @param {number[]} definition.capacites sièges de chaque table ; la table
* de rang i reçoit l'id et le numéro i + 1
* @param {number} definition.tours
* @param {number} [definition.effectif] membres, dont les groupes se tirent
* @param {number[]} [definition.tailles] tailles de groupe fixées, entiers
* ≥ 1, à la place du tirage 1 ; exclusives d'effectif
* @returns {import('../moteur/types.js').Configuration}
* @throws {TypeError} effectif et tailles donnés ensemble
* @throws {RangeError} moins de membres que de tables, quand chaque table
* reçoit un animateur ; effectif qui n'est pas un entier ≥ 0 ; taille fixée
* qui n'est pas un entier ≥ 1 ; graine hors de son intervalle
*/
export function engendrer({ graine, capacites, tours, effectif, tailles: taillesFixees }) {
if (effectif !== undefined && taillesFixees !== undefined) {
throw new TypeError("définition : effectif et tailles s'excluent");
}
const rng = creerPcg32(graine, FLUX.DEMO);
if (taillesFixees !== undefined) exigerTailles(taillesFixees);
const tailles = taillesFixees ?? tirerTailles(rng, effectif);
const N = somme(tailles);
const T = capacites.length;
if (N === 0) throw new ErreurConfiguration('DEMO_SANS_PARTICIPANT');
if (T === 0) throw new ErreurConfiguration('DEMO_SANS_TABLE');
if (N < T) throw new RangeError(`${N} membres pour ${T} tables : un animateur par table`);
const reservoirNoms = patronymes();
const reservoirOrganisations = organisations();
exigerReservoir('patronymes', N, reservoirNoms.length);
exigerReservoir('organisations', tailles.length, reservoirOrganisations.length);
const verdict = faisabilite(tailles, capacites);
if (!verdict.ok) {
throw new ErreurConfiguration('DEMO_SANS_TOUR_TEMOIN', { graine, raison: verdict.raison });
}
if (tourTemoin(tailles, capacites) === null) {
throw new ErreurConfiguration('DEMO_SANS_TOUR_TEMOIN', { graine, raison: 'CONSTRUCTION' });
}
const noms = tirerSansRemise(rng, reservoirNoms, N)
.map((nom, rang) => NOM_LONG_DE.get(rang + 1) ?? nom);
const prenoms = tirerPrenoms(rng, N);
const libelles = tirerSansRemise(rng, reservoirOrganisations, tailles.length);
const ordre = rng.melanger(idsJusqua(N));
const animateurs = rng.melanger(idsJusqua(N));
const appartenances = new Array(N);
let rang = 0;
for (let g = 0; g < tailles.length; g += 1) {
for (let m = 0; m < tailles[g]; m += 1, rang += 1) appartenances[ordre[rang] - 1] = libelles[g];
}
return {
participants: participantsDe(noms, prenoms, appartenances),
tables: tablesDe(capacites),
tours,
reservations: capacites.map((_, t) => ({
participant: animateurs[t],
table: t + 1,
portee: 'tous',
})),
contraintes: contraintesActives(),
};
}
// Petite démonstration (§ 15.3) : 12 personnes en appartenances fixées à la
// main, 4 tables de 3, 4 tours, aucune réservation. Tirages, dans l'ordre :
// un patronyme par personne, sans remise ; un prénom par personne, ids
// croissants, un borne() chacun ; un libellé d'organisation par groupe, sans
// remise, dans l'ordre des groupes.
function construirePetite(graine, groupes) {
const rng = creerPcg32(graine, FLUX.DEMO);
const noms = tirerSansRemise(rng, patronymes(), PETITE.membres);
const prenoms = tirerPrenoms(rng, PETITE.membres);
const libelles = tirerSansRemise(rng, organisations(), groupes.length);
const appartenances = new Array(PETITE.membres);
groupes.forEach((ids, g) => {
for (const id of ids) appartenances[id - 1] = libelles[g];
});
return {
participants: participantsDe(noms, prenoms, appartenances),
tables: tablesDe(Array(PETITE.tables).fill(PETITE.capacite)),
tours: PETITE.tours,
reservations: [],
contraintes: contraintesActives(),
};
}
// Entrée figée du catalogue ; construire() tire avec la graine de l'entrée.
function entree(cle, nom, graine, construire) {
return Object.freeze({ cle, nom, graine, construire: () => construire(graine) });
}
/**
* Les quatre démonstrations livrées (§ 15), dans l'ordre de présentation :
* { cle, nom, graine, construire() }. Les quatre activent toutes les
* contraintes. La variante sans exception reprend les tirages de la grande
* avec 33 tables de 8 ; la variante conflit reprend ceux de la petite, la
* personne 10 passée de C à A.
*/
export const CATALOGUE = Object.freeze([
entree('grande', 'Grande démonstration', GRAINE_GRANDE,
(graine) => engendrer({ graine, ...DEFINITION_GRANDE })),
entree('grande-sans-exception', 'Grande démonstration sans exception', GRAINE_GRANDE,
(graine) => engendrer({ graine, ...DEFINITION_SANS_EXCEPTION })),
entree('petite', 'Petite démonstration', GRAINE_PETITE,
(graine) => construirePetite(graine, GROUPES_PETITE)),
entree('petite-conflit', 'Petite démonstration, conflit inévitable', GRAINE_PETITE,
(graine) => construirePetite(graine, GROUPES_PETITE_CONFLIT)),
]);
// Fige un tableau et, à toute profondeur, les tableaux qu'il contient.
function figer(tableau) {
for (const element of tableau) if (Array.isArray(element)) figer(element);
return Object.freeze(tableau);
}
/**
* Le plan parfait de la petite démonstration (§ 15.3), en identifiants
* (§ 8.9) : à chaque tour, chaque table réunit un membre de A, de B et de C,
* et aucune paire ne se retrouve. Figé à toute profondeur.
* @type {import('../moteur/types.js').Plan}
*/
export const PLAN_PARFAIT_PETITE = Object.freeze({
tables: figer([1, 2, 3, 4]),
tours: figer([
[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]],
[[6, 9, 12], [3, 8, 11], [2, 5, 10], [1, 4, 7]],
[[4, 8, 10], [2, 7, 12], [1, 6, 11], [3, 5, 9]],
[[5, 7, 11], [1, 9, 10], [3, 4, 12], [2, 6, 8]],
]),
reserves: figer([[], [], [], []]),
});

View file

@ -0,0 +1,235 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Garde des noms forgés (§ 14.9, § 15.6) : aucun patronyme forgé complet, nom
// long compris, ni aucune racine d'organisation n'apparaît en mot entier dans
// un fichier texte du projet hors de src/demo/noms.js, qui les définit. Les
// données d'épreuve de test/fixtures/ sont balayées comme le reste.
//
// Un mot entier est bordé, de chaque côté, par autre chose qu'une lettre, une
// marque combinante ou un chiffre : le trait de soulignement et le trait
// d'union séparent deux mots. La comparaison ignore la casse et se fait sur
// le texte normalisé en NFC. Un fichier qui porte un octet nul dans ses
// 8000 premiers octets est binaire et n'est pas examiné, comme le décide git.
//
// Le balayage s'éprouve sur un arbre témoin avant de s'appliquer au projet.
// Chaque fichier de l'arbre exerce une règle : une sonde de test/fixtures/
// porte chaque nom cherché ; d'autres placent un nom au-delà du premier Mio
// du texte, en NFD, après un octet nul, dans une entrée cachée, dans un
// noms.js qui n'est pas celui des listes, ou sous un répertoire exclu ; trois
// derniers se lisent malgré un nom voisin d'une exclusion : src/distances/,
// distinctes.js et un fichier .git. L'épreuve affirme les fichiers lus, le
// compte exact des trouvailles, et que la garde de l'épreuve du projet
// désigne exactement les fichiers placés sous un répertoire exclu.
//
// L'épreuve établit l'absence dans ce projet, rien sur le monde réel : aucune
// vérification locale ne prouve qu'un nom forgé ne désigne nulle part une
// personne ou une organisation existante.
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, test } from '../../test/lanceur.js';
import { NOMS_LONGS, RACINES, patronymes } from './noms.js';
const RACINE = fileURLToPath(new URL('../..', import.meta.url));
const LISTES = join('src', 'demo', 'noms.js');
// Répertoires hors balayage, à toute profondeur : .git, les dépendances, les
// caches des outils, et les sorties de construction, qui portent une copie
// des listes mises en paquet (www, dist et chaque dist-…, plateformes de
// Capacitor). Un répertoire s'exclut par son nom exact, ou par le préfixe
// « dist- » : src/distances/ se balaie comme le reste. Un fichier ne
// s'exclut jamais par son nom : un module distinctes.js se lit, comme le
// fichier .git que porte un arbre de travail lié de git.
const HORS_BALAYAGE = new Set([
'.git', 'node_modules', 'www', 'dist', 'android', 'ios', 'coverage', '.vite', '.vitest',
]);
const horsBalayage = (nom) => HORS_BALAYAGE.has(nom) || nom.startsWith('dist-');
// Vrai quand un répertoire du chemin relatif est hors balayage ; le nom du
// fichier, dernier composant, n'entre pas en compte.
const sousUnRepertoireExclu = (chemin) => chemin.split(/[\\/]/).slice(0, -1).some(horsBalayage);
// Noms cherchés : chaque patronyme forgé, chaque nom long et chaque racine
// d'organisation.
const nomsCherches = () => [...patronymes(), ...NOMS_LONGS, ...RACINES];
// Fichiers de l'arbre de racine, hors des répertoires exclus, en chemins
// relatifs à racine, triés.
function fichiers(racine) {
const chemins = [];
const parcourir = (dossier) => {
for (const entree of readdirSync(dossier, { withFileTypes: true })) {
const chemin = join(dossier, entree.name);
if (entree.isDirectory()) {
if (!horsBalayage(entree.name)) parcourir(chemin);
} else if (entree.isFile()) {
chemins.push(relative(racine, chemin));
}
}
};
parcourir(racine);
return chemins.sort();
}
const estBinaire = (octets) => octets.subarray(0, 8000).includes(0);
const echapper = (texte) => texte.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Motif qui trouve chacun des noms en mot entier, sans égard à la casse.
function motifDes(noms) {
const alternatives = noms.map((nom) => echapper(nom.normalize('NFC'))).join('|');
return new RegExp(`(?<![\\p{L}\\p{M}\\p{N}])(?:${alternatives})(?![\\p{L}\\p{M}\\p{N}])`, 'giu');
}
// Occurrences du motif dans un texte, normalisé en NFC.
const occurrences = (motif, texte) => [...texte.normalize('NFC').matchAll(motif)].map(([mot]) => mot);
// Balaie l'arbre de racine : chaque fichier texte hors des répertoires exclus
// et des listes du générateur se lit en entier, décodé en UTF-8, et chaque
// nom cherché s'y relève. Rend les chemins examinés, relatifs à racine et
// triés, et les trouvailles { chemin, mot }, fichier après fichier, dans
// l'ordre du texte.
function balayer(racine) {
const motif = motifDes(nomsCherches());
const examines = [];
const trouvailles = [];
for (const chemin of fichiers(racine)) {
if (chemin === LISTES) continue;
const octets = readFileSync(join(racine, chemin));
if (estBinaire(octets)) continue;
examines.push(chemin);
for (const mot of occurrences(motif, octets.toString('utf8'))) trouvailles.push({ chemin, mot });
}
return { examines, trouvailles };
}
// Écrit dans un répertoire temporaire l'arbre donné, liste de paires [chemin
// relatif, contenu], le passe à examiner, puis l'efface ; rend ce que rend
// examiner.
function avecArbre(arbre, examiner) {
const racine = mkdtempSync(join(tmpdir(), 'noms-forges-'));
try {
for (const [chemin, contenu] of arbre) {
mkdirSync(dirname(join(racine, chemin)), { recursive: true });
writeFileSync(join(racine, chemin), contenu);
}
return examiner(racine);
} finally {
rmSync(racine, { recursive: true, force: true });
}
}
// Contenu dont l'octet d'indice indice est nul : des points jusqu'à lui, puis
// le nom sur la ligne suivante.
const nulA = (indice, nom) => Buffer.concat([Buffer.alloc(indice, '.'), Buffer.from(`\0\n${nom}\n`)]);
describe('noms forgés : balayage du projet (§ 15.6)', () => {
test('le motif trouve un nom forgé en mot entier, quelles que soient la casse et la forme normale', () => {
const [patronyme] = patronymes();
const [racine] = RACINES;
const long = NOMS_LONGS[1];
const motif = motifDes([patronyme, racine, long]);
// [texte, occurrences attendues]
const cas = [
[`« ${patronyme} »`, [patronyme]],
[`${patronyme.toUpperCase()},`, [patronyme.toUpperCase()]],
[`x_${racine}-y`, [racine]],
[long.normalize('NFD'), [long]],
[`x${patronyme}`, []],
[`${racine}s`, []],
[`${patronyme}2`, []],
];
const ecarts = cas
.filter(([texte, attendu]) => occurrences(motif, texte).join('|') !== attendu.join('|'))
.map(([texte]) => texte);
assert.deepEqual(ecarts, []);
});
test('arbre témoin : chaque nom trouvé, tout fichier texte lu en entier, seules les exclusions sautées', () => {
// Les noms attendus se calculent ici, sans nomsCherches : la sonde de
// test/fixtures/ en porte un par ligne, et chaque ligne doit être trouvée.
const noms = [...patronymes(), ...NOMS_LONGS, ...RACINES];
assert.ok(
patronymes().length > 0 && NOMS_LONGS.length > 0 && RACINES.length > 0,
'aucun nom forgé à chercher',
);
const [patronyme] = patronymes();
const enCapitales = RACINES[RACINES.length - 1].toUpperCase();
const sonde = join('test', 'fixtures', 'sonde.csv');
// [chemin, contenu, mots] : les fichiers lus, et les mots que le balayage
// doit y relever, dans l'ordre du texte.
const lus = [
[sonde, `${noms.join('\n')}\n`, noms],
// Au-delà du premier Mio : une lecture tronquée le manque.
[join('docs', 'loin.md'), `${'.'.repeat(2 ** 20)}\n${enCapitales}\n`, [enCapitales]],
['nfd.txt', `${NOMS_LONGS[1].normalize('NFD')}\n`, [NOMS_LONGS[1]]],
['nul-dehors.dat', nulA(8000, patronyme), [patronyme]],
// Une entrée cachée se lit, fichier ou répertoire.
['.editorconfig', patronyme, [patronyme]],
[join('.github', 'x.yml'), patronyme, [patronyme]],
// Les listes s'excluent par leur chemin, non par leur nom de fichier.
[join('autre', 'noms.js'), patronyme, [patronyme]],
// Un répertoire dont le nom commence comme celui d'un exclu se balaie,
// et un fichier ne s'exclut jamais par son nom : un arbre de travail lié
// de git porte un fichier .git.
[join('src', 'distances', 'a.js'), patronyme, [patronyme]],
[join('src', 'distinctes.js'), 'paires distinctes\n', []],
[join('sous', '.git'), 'gitdir: ../.git/worktrees/sous\n', []],
];
// [chemin, contenu] : les fichiers que le balayage saute — un octet nul
// dans les 8000 premiers, les listes du générateur.
const sautes = [
['nul-dedans.dat', nulA(7999, patronyme)],
[LISTES, patronyme],
];
// Un fichier sous chaque répertoire exclu, à la racine et plus profond ;
// chacun porte un nom forgé.
const exclus = [
join('node_modules', 'p', 'i.js'),
join('sous', 'node_modules', 'p', 'i.js'),
join('www', 'i.js'),
join('dist', 'i.js'),
join('dist-x', 'i.js'),
join('android', 'i.js'),
join('ios', 'i.js'),
join('coverage', 'i.html'),
join('.git', 'i'),
join('.vite', 'i.js'),
join('.vitest', 'i.json'),
];
const arbre = [
...lus.map(([chemin, contenu]) => [chemin, contenu]),
...sautes,
...exclus.map((chemin) => [chemin, patronyme]),
];
const { examines, trouvailles } = avecArbre(arbre, balayer);
const attendus = [...lus].sort(([a], [b]) => (a < b ? -1 : 1));
// Chaque fichier lu, et lui seul, avec le compte de ses trouvailles : un
// écart s'affiche en chemins et en comptes.
assert.deepEqual(
examines.map((chemin) => [chemin, trouvailles.filter((t) => t.chemin === chemin).length]),
attendus.map(([chemin, , mots]) => [chemin, mots.length]),
);
assert.equal(trouvailles.length, attendus.reduce((total, [, , mots]) => total + mots.length, 0));
// Hors de la sonde, chaque mot tel que l'écrit le texte normalisé.
assert.deepEqual(
trouvailles.filter((t) => t.chemin !== sonde).map((t) => t.mot),
attendus.filter(([chemin]) => chemin !== sonde).flatMap(([, , mots]) => mots),
);
// La garde de l'épreuve du projet désigne exactement les fichiers placés
// sous un répertoire exclu.
assert.deepEqual(arbre.map(([chemin]) => chemin).filter(sousUnRepertoireExclu), exclus);
});
test('aucun patronyme forgé ni aucune racine hors de src/demo/noms.js', () => {
assert.ok(nomsCherches().length > 0, 'aucun nom forgé à chercher');
const { examines, trouvailles } = balayer(RACINE);
assert.ok(examines.length > 0, 'balayage vide');
assert.ok(examines.includes(join('src', 'demo', 'catalogue.js')), 'src/demo/catalogue.js non balayé');
assert.deepEqual(examines.filter(sousUnRepertoireExclu), []);
assert.deepEqual(trouvailles.map(({ chemin, mot }) => `${chemin} : ${mot}`), []);
});
});

481
src/demo/catalogue.test.js Normal file
View file

@ -0,0 +1,481 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du catalogue de démonstrations (§ 15) : ses quatre entrées, le
// déterminisme, les grandeurs de chacune, l'ordre contractuel des tirages
// (§ 15.5, point 3), le plan parfait de la petite et les refus du générateur
// (§ 15.4). Le profil d'appartenances de la grande n'est écrit nulle part ici
// (§ 15.2) : il se recalcule en rejouant le tirage des tailles.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { indexerPlan, normaliser } from '../moteur/configuration.js';
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { tirerTailles, tourTemoin } from './appartenances.js';
import { CATALOGUE, PLAN_PARFAIT_PETITE, engendrer } from './catalogue.js';
import { NOMS_LONGS, PRENOMS, organisations, patronymes } from './noms.js';
import { FLUX, creerPcg32 } from './prng.js';
const CLES = ['grande', 'grande-sans-exception', 'petite', 'petite-conflit'];
const TOUTES_ACTIVES = {
separerAppartenances: true,
nouveauxVoisins: true,
nouvelleTable: true,
varierAppartenances: true,
};
// Appartenances de la petite démonstration (§ 15.3), puis de sa variante,
// où la personne 10 passe de C à A.
const A = [1, 5, 8, 12];
const B = [2, 4, 9, 11];
const C = [3, 6, 7, 10];
const A_CONFLIT = [1, 5, 8, 10, 12];
const C_CONFLIT = [3, 6, 7];
// Le plan parfait de la petite démonstration, recopié du tableau du § 15.3 :
// TABLEAU_15_3[r][i] = ids assis à la table i + 1 au tour r + 1.
const TABLEAU_15_3 = [
[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]],
[[6, 9, 12], [3, 8, 11], [2, 5, 10], [1, 4, 7]],
[[4, 8, 10], [2, 7, 12], [1, 6, 11], [3, 5, 9]],
[[5, 7, 11], [1, 9, 10], [3, 4, 12], [2, 6, 8]],
];
// Entiers de debut à fin, inclus.
const suite = (debut, fin) => Array.from({ length: fin - debut + 1 }, (_, i) => debut + i);
const somme = (liste) => liste.reduce((a, b) => a + b, 0);
const croissant = (a, b) => a - b;
const entree = (cle) => CATALOGUE.find((e) => e.cle === cle);
// Configuration d'une entrée, construite à la première demande puis gardée :
// une entrée absente fait échouer l'épreuve qui la demande, non le chargement
// du fichier. Aucune épreuve ne modifie ce qu'elle reçoit.
const construites = new Map();
function configurationDe(cle) {
if (!construites.has(cle)) construites.set(cle, entree(cle).construire());
return construites.get(cle);
}
// Membres de chaque appartenance, en ids croissants, les groupes rangés par
// leur plus petit id. La Map retrouve le groupe d'un libellé ; le tri fixe
// l'ordre de sortie.
function groupesDe({ participants }) {
const membres = new Map();
for (const { id, appartenance } of participants) {
if (!membres.has(appartenance)) membres.set(appartenance, []);
membres.get(appartenance).push(id);
}
return [...membres.values()].map((ids) => ids.sort(croissant)).sort((a, b) => a[0] - b[0]);
}
// Objets d'une configuration : elle-même, ses listes, leurs éléments et ses
// contraintes.
const objetsDe = (c) => [
c, c.participants, ...c.participants, c.tables, ...c.tables,
c.reservations, ...c.reservations, c.contraintes,
];
// Le tirage sans remise que décrit catalogue.js : mélange partiel de Fisher et
// Yates, de la première case vers la dernière, un borne() par élément tiré.
function tirerSansRemise(rng, reservoir, k) {
const pile = [...reservoir];
for (let i = 0; i < k; i += 1) {
const j = i + rng.borne(pile.length - i);
[pile[i], pile[j]] = [pile[j], pile[i]];
}
return pile.slice(0, k);
}
// Tables de 8 sièges, assez nombreuses pour asseoir les membres donnés avec
// une table de marge.
const tablesPour = (membres) => Array(Math.ceil(membres / 8) + 1).fill(8);
// Code et détails de l'ErreurConfiguration que lève l'appel ; toute autre
// issue est décrite en une ligne.
function refus(appel) {
try {
appel();
} catch (erreur) {
if (erreur instanceof ErreurConfiguration) return { code: erreur.code, details: erreur.details };
return `${erreur.name} : ${erreur.message}`;
}
return 'aucune erreur';
}
describe('catalogue : les entrées', () => {
test('quatre entrées, dans cet ordre, chacune nommée, ensemencée et figée', () => {
assert.deepEqual(CATALOGUE.map((e) => e.cle), CLES);
assert.ok(Object.isFrozen(CATALOGUE));
assert.ok(CATALOGUE.every((e) => Object.isFrozen(e)));
const noms = CATALOGUE.map((e) => e.nom);
assert.ok(noms.every((nom) => typeof nom === 'string' && nom.length > 0));
assert.equal(new Set(noms).size, CLES.length);
assert.ok(CATALOGUE.every((e) => Number.isInteger(e.graine) && e.graine >= 0 && e.graine < 2 ** 32));
assert.ok(CATALOGUE.every((e) => typeof e.construire === 'function'));
});
test('chaque variante tire avec la graine de sa démonstration', () => {
assert.equal(entree('grande-sans-exception').graine, entree('grande').graine);
assert.equal(entree('petite-conflit').graine, entree('petite').graine);
});
});
describe('catalogue : déterminisme (§ 15.5)', () => {
test('construire() deux fois rend deux configurations égales', () => {
assert.equal(CATALOGUE.length, CLES.length);
for (const e of CATALOGUE) assert.deepStrictEqual(e.construire(), e.construire(), e.cle);
});
test('construire() rend des objets neufs, aucun partagé avec un appel précédent', () => {
assert.equal(CATALOGUE.length, CLES.length);
for (const e of CATALOGUE) {
const premiers = new Set(objetsDe(e.construire()));
assert.equal(objetsDe(e.construire()).filter((objet) => premiers.has(objet)).length, 0, e.cle);
}
});
});
describe('catalogue : grande (§ 15.1)', () => {
test('260 participants, ids 1 à 260, chacun nommé, prénommé et membre d’une organisation', () => {
const { participants } = configurationDe('grande');
assert.deepEqual(participants.map((p) => p.id), suite(1, 260));
const prenoms = new Set(PRENOMS);
const reservoir = new Set(organisations());
const ecarts = participants
.filter((p) => typeof p.nom !== 'string' || !prenoms.has(p.prenom) || !reservoir.has(p.appartenance)
|| p.exclu !== undefined)
.map((p) => p.id);
assert.deepEqual(ecarts, []);
});
test('33 tables, ids et numéros 1 à 33, 8 sièges sauf 30 à 33 à 7 : Σ c_t = 260', () => {
const { tables } = configurationDe('grande');
assert.deepEqual(tables, suite(1, 33).map((id) => ({ id, numero: id, capacite: id >= 30 ? 7 : 8 })));
assert.equal(somme(tables.map((t) => t.capacite)), 260);
});
test('4 tours, les quatre contraintes actives', () => {
const configuration = configurationDe('grande');
assert.equal(configuration.tours, 4);
assert.deepEqual(configuration.contraintes, TOUTES_ACTIVES);
});
test('33 réservations « tous », une par table, animateurs distincts', () => {
const { reservations } = configurationDe('grande');
assert.equal(reservations.length, 33);
assert.ok(reservations.every((r) => r.portee === 'tous' && !('tour' in r)));
assert.deepEqual(reservations.map((r) => r.table).sort(croissant), suite(1, 33));
const animateurs = reservations.map((r) => r.participant);
assert.equal(new Set(animateurs).size, 33);
assert.ok(animateurs.every((id) => Number.isInteger(id) && id >= 1 && id <= 260));
});
test('les animateurs ne partagent pas tous une même appartenance', () => {
const { participants, reservations } = configurationDe('grande');
const appartenanceDe = new Map(participants.map((p) => [p.id, p.appartenance]));
const appartenances = new Set(reservations.map((r) => appartenanceDe.get(r.participant)));
assert.ok(appartenances.size > 1);
});
test('tailles de groupe de 1 à 12, somme 260 : celles que rejoue le tirage des tailles', () => {
const configuration = configurationDe('grande');
const tailles = groupesDe(configuration).map((membres) => membres.length);
assert.ok(tailles.every((taille) => taille >= 1 && taille <= 12), JSON.stringify(tailles));
assert.equal(somme(tailles), 260);
const tirees = tirerTailles(creerPcg32(entree('grande').graine, FLUX.DEMO), 260);
assert.deepEqual([...tailles].sort(croissant), [...tirees].sort(croissant));
assert.notEqual(tourTemoin(tirees, configuration.tables.map((t) => t.capacite)), null);
});
test('260 patronymes distincts ; les ids 7, 77 et 177 portent les trois noms longs', () => {
const { participants } = configurationDe('grande');
const noms = participants.map((p) => p.nom);
assert.equal(new Set(noms).size, 260);
assert.deepEqual([noms[6], noms[76], noms[176]], [...NOMS_LONGS]);
const reservoir = new Set(patronymes());
const horsReservoir = participants.filter((p) => !reservoir.has(p.nom)).map((p) => p.id);
assert.deepEqual(horsReservoir, [7, 77, 177]);
});
test('exactement tendue : 33 ancrés, 227 mobiles, capacité libre 227', () => {
const instance = normaliser(configurationDe('grande'));
assert.deepEqual([instance.N, instance.T, instance.R, instance.k, instance.n], [260, 33, 4, 33, 227]);
let libre = 0;
for (let t = 0; t < instance.T; t += 1) libre += instance.capacite[t] - instance.ancresParTable[t];
assert.equal(libre, instance.n);
});
test('l’ordre des tirages suit le contrat (§ 15.5, point 3)', () => {
const { participants, reservations } = configurationDe('grande');
const N = 260;
const rng = creerPcg32(entree('grande').graine, FLUX.DEMO);
const tailles = tirerTailles(rng, N); // (1)
const noms = tirerSansRemise(rng, patronymes(), N); // (2)
const prenoms = suite(1, N).map(() => PRENOMS[rng.borne(PRENOMS.length)]); // (3)
const libelles = tirerSansRemise(rng, organisations(), tailles.length); // (4)
const ordre = rng.melanger(suite(1, N)); // (5)
const animateurs = rng.melanger(suite(1, N)).slice(0, 33); // (6)
const appartenance = [];
let rang = 0;
tailles.forEach((taille, g) => {
for (let m = 0; m < taille; m += 1, rang += 1) appartenance[ordre[rang]] = libelles[g];
});
const longs = new Map([[7, NOMS_LONGS[0]], [77, NOMS_LONGS[1]], [177, NOMS_LONGS[2]]]);
assert.deepEqual(
participants,
suite(1, N).map((id) => ({
id,
nom: longs.get(id) ?? noms[id - 1],
prenom: prenoms[id - 1],
appartenance: appartenance[id],
})),
);
assert.deepEqual(
reservations,
animateurs.map((participant, t) => ({ participant, table: t + 1, portee: 'tous' })),
);
});
});
describe('catalogue : grande sans exception (§ 15.1)', () => {
test('mêmes participants, appartenances et animateurs que la grande', () => {
const grande = configurationDe('grande');
const variante = configurationDe('grande-sans-exception');
assert.deepStrictEqual(variante.participants, grande.participants);
assert.deepStrictEqual(variante.reservations, grande.reservations);
assert.equal(variante.tours, grande.tours);
assert.deepStrictEqual(variante.contraintes, grande.contraintes);
});
test('33 tables de 8, ids et numéros 1 à 33 : 264 sièges', () => {
assert.deepEqual(
configurationDe('grande-sans-exception').tables,
suite(1, 33).map((id) => ({ id, numero: id, capacite: 8 })),
);
});
});
describe('catalogue : petite et conflit inévitable (§ 15.3)', () => {
test('12 participants, 4 tables de 3, 4 tours, aucune réservation, contraintes actives', () => {
for (const c of [configurationDe('petite'), configurationDe('petite-conflit')]) {
assert.deepEqual(c.participants.map((p) => p.id), suite(1, 12));
assert.deepEqual(c.tables, suite(1, 4).map((id) => ({ id, numero: id, capacite: 3 })));
assert.equal(c.tours, 4);
assert.deepEqual(c.reservations, []);
assert.deepEqual(c.contraintes, TOUTES_ACTIVES);
}
});
test('appartenances A, B, C : 4, 4, 4 ; dans la variante 5, 4, 3', () => {
const conflit = configurationDe('petite-conflit');
assert.deepEqual(groupesDe(configurationDe('petite')), [A, B, C]);
assert.deepEqual(groupesDe(conflit), [A_CONFLIT, B, C_CONFLIT]);
assert.deepEqual(groupesDe(conflit).map((g) => g.length), [5, 4, 3]);
});
test('trois libellés distincts tirés parmi les organisations ; noms et prénoms des réservoirs', () => {
const configuration = configurationDe('petite');
const libelles = new Set(configuration.participants.map((p) => p.appartenance));
assert.equal(libelles.size, 3);
const reservoir = new Set(organisations());
assert.ok([...libelles].every((libelle) => reservoir.has(libelle)));
const noms = configuration.participants.map((p) => p.nom);
assert.equal(new Set(noms).size, 12);
const patronymesConnus = new Set(patronymes());
assert.ok(noms.every((nom) => patronymesConnus.has(nom)));
assert.ok(configuration.participants.every((p) => PRENOMS.includes(p.prenom)));
});
test('la variante ne change que l’appartenance de la personne 10', () => {
const { participants } = configurationDe('petite');
const conflit = configurationDe('petite-conflit');
const libelleA = participants[0].appartenance;
assert.deepEqual(
conflit.participants,
participants.map((p) => (p.id === 10 ? { ...p, appartenance: libelleA } : p)),
);
});
test('l’ordre des tirages : patronymes, prénoms, organisations', () => {
const configuration = configurationDe('petite');
const rng = creerPcg32(entree('petite').graine, FLUX.DEMO);
const noms = tirerSansRemise(rng, patronymes(), 12);
const prenoms = suite(1, 12).map(() => PRENOMS[rng.borne(PRENOMS.length)]);
const [a, b, c] = tirerSansRemise(rng, organisations(), 3);
const libelleDe = (id) => (A.includes(id) ? a : B.includes(id) ? b : c);
assert.deepEqual(
configuration.participants,
suite(1, 12).map((id) => ({ id, nom: noms[id - 1], prenom: prenoms[id - 1], appartenance: libelleDe(id) })),
);
});
});
describe('catalogue : plan parfait de la petite (§ 15.3)', () => {
test('le tableau du § 15.3, en ids, sans réserve', () => {
assert.deepEqual(PLAN_PARFAIT_PETITE, {
tables: [1, 2, 3, 4],
tours: TABLEAU_15_3,
reserves: [[], [], [], []],
});
});
test('partition à chaque tour, aucune paire répétée, 4 tables et 8 rencontres chacun, aucune collision', () => {
const groupeDe = (id) => (A.includes(id) ? 'A' : B.includes(id) ? 'B' : 'C');
const ecarts = [];
const paires = new Set();
const rencontres = new Map(suite(1, 12).map((id) => [id, new Set()]));
const visitees = new Map(suite(1, 12).map((id) => [id, new Set()]));
PLAN_PARFAIT_PETITE.tours.forEach((listes, r) => {
const assis = listes.flat().sort(croissant);
if (assis.join() !== suite(1, 12).join()) ecarts.push(`tour ${r + 1} : pas une partition`);
listes.forEach((liste, i) => {
const table = PLAN_PARFAIT_PETITE.tables[i];
if (liste.length !== 3) ecarts.push(`tour ${r + 1}, table ${table} : ${liste.length} assis`);
if (new Set(liste.map(groupeDe)).size !== liste.length) {
ecarts.push(`tour ${r + 1}, table ${table} : collision`);
}
for (const x of liste) {
visitees.get(x).add(table);
for (const y of liste) {
if (x === y) continue;
rencontres.get(x).add(y);
if (x < y) {
if (paires.has(`${x}-${y}`)) ecarts.push(`paire ${x}-${y} répétée`);
paires.add(`${x}-${y}`);
}
}
}
});
});
for (const id of suite(1, 12)) {
if (visitees.get(id).size !== 4) ecarts.push(`${id} : ${visitees.get(id).size} tables`);
if (rencontres.get(id).size !== 8) ecarts.push(`${id} : ${rencontres.get(id).size} rencontres`);
}
assert.deepEqual(ecarts, []);
});
test('il s’indexe sur l’instance de la petite démonstration', () => {
const instance = normaliser(configurationDe('petite'));
assert.equal(indexerPlan(instance, PLAN_PARFAIT_PETITE).length, 12 * 4);
});
test('il est figé jusque dans ses listes', () => {
const { tables, tours, reserves } = PLAN_PARFAIT_PETITE;
const objets = [PLAN_PARFAIT_PETITE, tables, tours, ...tours, ...tours.flat(), reserves, ...reserves];
assert.ok(objets.every((objet) => Object.isFrozen(objet)));
});
});
describe('engendrer : refus (§ 15.4, § 15.6)', () => {
// Les quatre refus du § 14.10 — zéro participant, zéro table, aucun tour
// témoin, réservoir de noms dépassé —, chacun sur un ou plusieurs cas, une
// épreuve par cas. Un cas qui rompt deux règles à la fois attend la
// première dans l'ordre du contrôle : participants, tables, réservoirs,
// tour témoin.
const nombreOrganisations = organisations().length;
const nombrePatronymes = patronymes().length;
// Des groupes de deux, juste assez pour dépasser les patronymes et pas les
// organisations ; des tables de 8 en nombre suffisant pour un tour.
const paires = Math.floor(nombrePatronymes / 2) + 1;
// Un groupe d'une personne de plus qu'il n'y a d'organisations.
const tropDeGroupes = Object.freeze(Array(nombreOrganisations + 1).fill(1));
const organisationsDepassees = {
code: 'DEMO_RESERVOIR',
details: { reservoir: 'organisations', demandes: nombreOrganisations + 1, disponibles: nombreOrganisations },
};
// [cas, définition, refus attendu]
const cas = [
['zéro participant', { graine: 1, effectif: 0, capacites: [8, 8], tours: 4 },
{ code: 'DEMO_SANS_PARTICIPANT', details: {} }],
['zéro participant, tailles fixées', { graine: 1, tailles: [], capacites: [8, 8], tours: 4 },
{ code: 'DEMO_SANS_PARTICIPANT', details: {} }],
['zéro participant et zéro table : les participants d’abord',
{ graine: 1, tailles: [], capacites: [], tours: 4 },
{ code: 'DEMO_SANS_PARTICIPANT', details: {} }],
['zéro table', { graine: 1, effectif: 260, capacites: [], tours: 4 },
{ code: 'DEMO_SANS_TABLE', details: {} }],
['zéro table et plus de groupes que d’organisations : les tables d’abord',
{ graine: 1, tailles: tropDeGroupes, capacites: [], tours: 4 },
{ code: 'DEMO_SANS_TABLE', details: {} }],
['§ 15.2 : tables de 10, 1 et 1 pour quatre groupes de 3',
{ graine: 1, tailles: [3, 3, 3, 3], capacites: [10, 1, 1], tours: 4 },
{ code: 'DEMO_SANS_TOUR_TEMOIN', details: { graine: 1, raison: 'PLUS_GRANDE_CAPACITE' } }],
['un groupe de 4 pour 3 tables', { graine: 1, tailles: [4, 2], capacites: [3, 3, 3], tours: 4 },
{ code: 'DEMO_SANS_TOUR_TEMOIN', details: { graine: 1, raison: 'PLUS_GROS_GROUPE' } }],
['inégalités tenues, aucun tour : tables de 6, 4 et 2',
{ graine: 1, tailles: [3, 3, 3, 1, 1, 1], capacites: [6, 4, 2], tours: 4 },
{ code: 'DEMO_SANS_TOUR_TEMOIN', details: { graine: 1, raison: 'CONSTRUCTION' } }],
['plus de groupes que d’organisations',
{ graine: 1, tailles: tropDeGroupes, capacites: tablesPour(nombreOrganisations + 1), tours: 4 },
organisationsDepassees],
['plus de groupes que d’organisations, sur une seule table : les réservoirs avant le tour témoin',
{ graine: 1, tailles: tropDeGroupes, capacites: [8], tours: 4 },
organisationsDepassees],
['plus de membres que de patronymes',
{ graine: 1, tailles: Array(paires).fill(2), capacites: tablesPour(2 * paires), tours: 4 },
{ code: 'DEMO_RESERVOIR',
details: { reservoir: 'patronymes', demandes: 2 * paires, disponibles: nombrePatronymes } }],
];
test('les réservoirs laissent à chaque cas une seule règle de réservoir rompue', () => {
assert.ok(nombreOrganisations + 1 <= nombrePatronymes, 'trop peu de patronymes pour le cas des organisations');
assert.ok(paires <= nombreOrganisations, 'trop peu d’organisations pour le cas des patronymes');
});
for (const [quoi, definition, attendu] of cas) {
test(`${quoi} : ${attendu.code}`, () => {
assert.deepEqual(refus(() => engendrer(definition)), attendu);
});
}
test('un réservoir employé jusqu’à son dernier nom suffit', () => {
const parOrganisation = engendrer({
graine: 1,
tailles: Array(nombreOrganisations).fill(1),
capacites: tablesPour(nombreOrganisations),
tours: 4,
});
assert.equal(new Set(parOrganisation.participants.map((p) => p.appartenance)).size, nombreOrganisations);
const tailles = Array(Math.floor(nombrePatronymes / 2)).fill(2);
if (nombrePatronymes % 2 === 1) tailles.push(1);
const parPatronyme = engendrer({ graine: 1, tailles, capacites: tablesPour(nombrePatronymes), tours: 4 });
assert.equal(new Set(parPatronyme.participants.map((p) => p.nom)).size, nombrePatronymes);
});
test('une définition qui donne à la fois un effectif et des tailles lève TypeError', () => {
assert.throws(
() => engendrer({ graine: 1, effectif: 12, tailles: [4, 4, 4], capacites: [3, 3, 3, 3], tours: 4 }),
TypeError,
);
});
test('une taille fixée qui n’est pas un entier ≥ 1 lève RangeError', () => {
// Sur deux tables de 8, chacune de ces définitions rend, sans le
// contrôle, une configuration aux groupes faussés ou un refus étranger à
// la taille fautive.
const ecarts = [[-2, 2, 2], [1.5, 1.5], [0, 2], ['2', 2]]
.map((tailles) => [tailles, refus(() => engendrer({ graine: 1, tailles, capacites: [8, 8], tours: 4 }))])
.filter(([, issue]) => typeof issue !== 'string' || !issue.startsWith('RangeError : tailles'))
.map(([tailles, issue]) => `${JSON.stringify(tailles)} : ${JSON.stringify(issue)}`);
assert.deepEqual(ecarts, []);
});
test('moins de membres que de tables lève RangeError : un animateur par table', () => {
assert.throws(
() => engendrer({ graine: 1, tailles: [1, 1], capacites: [8, 8, 8], tours: 4 }),
RangeError,
);
});
test('autant de membres que de tables : chacun anime une table, sur les tours de la définition', () => {
const { participants, reservations, tours } = engendrer({
graine: 1,
tailles: [1, 1, 1],
capacites: [8, 8, 8],
tours: 3,
});
assert.equal(tours, 3);
assert.deepEqual(participants.map((p) => p.id), [1, 2, 3]);
assert.deepEqual(reservations.map((r) => r.table), [1, 2, 3]);
assert.deepEqual(reservations.map((r) => r.participant).sort(croissant), [1, 2, 3]);
});
});

81
src/demo/noms.js Normal file
View file

@ -0,0 +1,81 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Réservoirs de noms des démonstrations (§ 15.6) : des prénoms courants, des
// patronymes forgés par produit d'un préfixe et d'une terminaison, des noms
// d'organisation formés d'une tête, qui dit le genre d'organisation, et d'une
// racine forgée. Les produits se tirent sans remise : chaque personne reçoit
// un patronyme distinct, chaque appartenance un libellé distinct, sans boucle
// de rattrapage. Les fragments sont forgés pour ne désigner personne ;
// catalogue.long.test.js refuse qu'un patronyme complet ou une racine
// apparaisse ailleurs dans le projet.
//
// L'ordre de chaque liste fait partie du contrat des tirages (§ 15.5,
// point 3) : un tirage désigne un rang, et changer un ordre change les
// démonstrations. Les listes sont figées et en NFC.
/** Prénoms courants, tirés avec remise, un par personne. */
export const PRENOMS = Object.freeze([
'Marie', 'Jean', 'Louise', 'Pierre', 'Camille', 'Luc', 'Julie', 'Marc',
'Sophie', 'Paul', 'Claire', 'Michel', 'Anne', 'Louis', 'Isabelle', 'François',
'Catherine', 'Nicolas', 'Nathalie', 'Philippe', 'Émilie', 'Antoine', 'Chantal', 'Simon',
'Geneviève', 'Gabriel', 'Hélène', 'Olivier', 'Véronique', 'Alexandre', 'Sylvie', 'Guillaume',
'Caroline', 'Étienne', 'Lucie', 'Félix', 'Manon', 'Samuel', 'Chloé', 'Raphaël',
'Léa', 'Thomas', 'Zoé', 'Hugo', 'Amina', 'Karim', 'Fatima', 'Youssef',
]);
/** Débuts de patronyme forgés, chacun terminé par une consonne. */
export const PREFIXES = Object.freeze([
'Abrev', 'Bralm', 'Clov', 'Dulv', 'Esk', 'Fralv', 'Gorz', 'Hulv', 'Ivr', 'Jusv', 'Korv',
'Lusk', 'Mirv', 'Nesk', 'Obr', 'Pruv', 'Quilm', 'Rozv', 'Selv', 'Tolv', 'Urv', 'Vosk',
]);
/** Fins de patronyme, chacune ouverte par une voyelle. */
export const TERMINAISONS = Object.freeze([
'oubre', 'ard', 'ière', 'ault', 'ignac', 'eron', 'ouze', 'ivet',
'esson', 'ande', 'ichon', 'eval', 'otte', 'ançon', 'eux', 'umet',
]);
/** Têtes de nom d'organisation : le mot qui dit qu'il s'agit d'une organisation. */
export const TETES = Object.freeze([
'Ateliers', 'Coopérative', 'Verrerie', 'Fonderie', 'Imprimerie', 'Brasserie',
'Menuiserie', 'Papeterie', 'Filature', 'Tannerie', 'Comptoir', 'Fabrique',
]);
/** Racines forgées des noms d'organisation. */
export const RACINES = Object.freeze([
'Aubrenc', 'Belvarre', 'Cassorgue', 'Dravenne', 'Estrolle', 'Falvigne', 'Gravelune',
'Hostrenne', 'Ivoranche', 'Jolvane', 'Kalvenne', 'Lavorneau', 'Morvalle', 'Nervanche',
'Olbrecq', 'Pravette', 'Quimvaux', 'Rossolde', 'Sorbagne', 'Tavrenne', 'Ulvardes',
'Vaubrenne', 'Wassorel', 'Xandrelle', 'Yzorne', 'Zelvaine',
]);
/**
* Trois patronymes forgés longs, hors du produit des fragments, qui éprouvent
* la mise en page d'un nom (§ 11.5, § 15.4) : le premier en capitales larges,
* M et W ; le deuxième chargé de diacritiques ; le troisième très long.
*/
export const NOMS_LONGS = Object.freeze([
'WAMMERWOLM-MAWWEMMEL',
'Bérêçàuvïlle-Dœûmôntëÿ',
'Vandergrolcquesmirtavelle-Ostrovaubelindigues',
]);
/**
* Patronymes forgés : le produit PREFIXES × TERMINAISONS, préfixe par
* préfixe, chaque préfixe suivi de chaque terminaison dans l'ordre. Une liste
* neuve à chaque appel.
* @returns {string[]}
*/
export function patronymes() {
return PREFIXES.flatMap((prefixe) => TERMINAISONS.map((terminaison) => prefixe + terminaison));
}
/**
* Noms d'organisation : le produit TETES × RACINES, tête par tête, la tête et
* la racine séparées par une espace. Une liste neuve à chaque appel.
* @returns {string[]}
*/
export function organisations() {
return TETES.flatMap((tete) => RACINES.map((racine) => `${tete} ${racine}`));
}

156
src/demo/noms.test.js Normal file
View file

@ -0,0 +1,156 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des réservoirs de noms du générateur (§ 15.4, § 15.6) : tailles,
// unicité, ordre des produits, forme des fragments, noms longs. Ce fichier
// n'écrit aucun nom forgé : il les lit dans noms.js, seul fichier où le
// balayage de catalogue.long.test.js les admet.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import {
NOMS_LONGS,
PREFIXES,
PRENOMS,
RACINES,
TERMINAISONS,
TETES,
organisations,
patronymes,
} from './noms.js';
// [nom de la liste, liste, taille minimale du plan]
const LISTES = [
['PRENOMS', PRENOMS, 40],
['PREFIXES', PREFIXES, 20],
['TERMINAISONS', TERMINAISONS, 16],
['TETES', TETES, 10],
['RACINES', RACINES, 26],
];
const distincts = (liste) => new Set(liste).size;
// Un mot : une capitale suivie de minuscules, sans espace, trait d'union ni
// chiffre. Un nom de cette forme se cherche en mot entier sans ambiguïté.
const MOT = /^\p{Lu}\p{Ll}+$/u;
// Caractères d'une chaîne, en points de code.
const caracteres = (texte) => [...texte];
describe('noms : listes', () => {
test('chaque liste atteint sa taille minimale, sans doublon, figée et en NFC', () => {
const ecarts = [];
for (const [nom, liste, minimum] of LISTES) {
if (liste.length < minimum) ecarts.push(`${nom} : ${liste.length} éléments, minimum ${minimum}`);
if (distincts(liste) !== liste.length) ecarts.push(`${nom} : doublon`);
if (!Object.isFrozen(liste)) ecarts.push(`${nom} : modifiable`);
if (liste.some((element) => element !== element.normalize('NFC'))) {
ecarts.push(`${nom} : élément hors NFC`);
}
}
assert.deepEqual(ecarts, []);
});
test('les fragments forgés et les têtes ont la forme de mots', () => {
assert.ok([PREFIXES, TERMINAISONS, RACINES, TETES].every((liste) => liste.length > 0), 'liste vide');
const ecarts = [
...PREFIXES.filter((p) => !/^\p{Lu}\p{Ll}*$/u.test(p)).map((p) => `préfixe ${p}`),
...TERMINAISONS.filter((t) => !/^\p{Ll}+$/u.test(t)).map((t) => `terminaison ${t}`),
...RACINES.filter((r) => !MOT.test(r)).map((r) => `racine ${r}`),
...TETES.filter((t) => !MOT.test(t)).map((t) => `tête ${t}`),
];
assert.deepEqual(ecarts, []);
});
});
describe('noms : patronymes', () => {
test('au moins 320 patronymes distincts, chacun un mot', () => {
const liste = patronymes();
assert.ok(liste.length >= 320, `${liste.length} patronymes`);
assert.equal(distincts(liste), liste.length);
assert.deepEqual(liste.filter((p) => !MOT.test(p)).length, 0);
});
test('le produit PREFIXES × TERMINAISONS, préfixe par préfixe', () => {
assert.ok(PREFIXES.length > 0 && TERMINAISONS.length > 0, 'liste vide');
assert.deepEqual(
patronymes(),
PREFIXES.flatMap((prefixe) => TERMINAISONS.map((terminaison) => prefixe + terminaison)),
);
});
test('chaque appel rend une liste neuve', () => {
const premiere = patronymes();
assert.ok(premiere.length > 0, 'aucun patronyme');
premiere.length = 0;
assert.equal(patronymes().length, PREFIXES.length * TERMINAISONS.length);
});
});
describe('noms : organisations', () => {
test('au moins 260 organisations distinctes', () => {
const liste = organisations();
assert.ok(liste.length >= 260, `${liste.length} organisations`);
assert.equal(distincts(liste), liste.length);
});
test('le produit TETES × RACINES, tête par tête, séparées par une espace', () => {
assert.ok(TETES.length > 0 && RACINES.length > 0, 'liste vide');
assert.deepEqual(
organisations(),
TETES.flatMap((tete) => RACINES.map((racine) => `${tete} ${racine}`)),
);
});
test('chaque appel rend une liste neuve', () => {
const premiere = organisations();
assert.ok(premiere.length > 0, 'aucune organisation');
premiere.length = 0;
assert.equal(organisations().length, TETES.length * RACINES.length);
});
test('aucune racine n’est aussi un patronyme', () => {
const noms = new Set(patronymes());
assert.ok(noms.size > 0 && RACINES.length > 0, 'liste vide');
assert.deepEqual(RACINES.filter((racine) => noms.has(racine)).length, 0);
});
});
describe('noms : noms longs (§ 15.4)', () => {
test('trois noms distincts, figés, en NFC, hors du réservoir des patronymes et des racines', () => {
assert.equal(NOMS_LONGS.length, 3);
assert.equal(distincts(NOMS_LONGS), 3);
assert.ok(Object.isFrozen(NOMS_LONGS));
const reservoir = new Set([...patronymes(), ...RACINES]);
assert.deepEqual(
NOMS_LONGS.filter((nom) => reservoir.has(nom) || nom !== nom.normalize('NFC')).length,
0,
);
});
test('chacun compte au moins 20 caractères, plus que tout patronyme du réservoir', () => {
const plusLong = Math.max(...patronymes().map((nom) => caracteres(nom).length));
assert.ok(Number.isInteger(plusLong), 'aucun patronyme');
assert.ok(NOMS_LONGS.length > 0, 'aucun nom long');
const seuil = Math.max(20, plusLong + 1);
// [rang du nom long, caractères] de chaque nom sous le seuil.
const courts = NOMS_LONGS.map((nom, rang) => [rang, caracteres(nom).length]).filter(([, n]) => n < seuil);
assert.deepEqual(courts, []);
});
test('le premier est fait de glyphes larges : M et W pour au moins la moitié de ses caractères', () => {
const tous = caracteres(NOMS_LONGS[0]);
const larges = tous.filter((c) => /[MWmw]/u.test(c));
assert.ok(larges.length > 0 && larges.length * 2 >= tous.length, `${larges.length} sur ${tous.length}`);
});
test('le deuxième porte au moins cinq lettres à diacritique', () => {
const marques = [...NOMS_LONGS[1].normalize('NFD')].filter((c) => /\p{M}/u.test(c));
assert.ok(marques.length >= 5, `${marques.length} diacritiques`);
});
test('le troisième est le plus long, et compte au moins 40 caractères', () => {
const [large, accentue, long] = NOMS_LONGS.map((nom) => caracteres(nom).length);
assert.ok(long >= 40, `${long} caractères`);
assert.ok(long > large && long > accentue);
});
});

143
src/demo/prng.js Normal file
View file

@ -0,0 +1,143 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le générateur pseudo-aléatoire unique du projet (§ 15.5, point 1) : PCG32,
// variante XSH RR 64/32 de pcg_basic.c. L'état et l'incrément sont des entiers
// de 64 bits, portés chacun en deux mots non signés de 32 bits, haut et bas.
// Un pas fait état ← état × 6364136223846793005 + incrément, modulo 2^64 ;
// chaque tirage rend 32 bits de l'état d'avant le pas, mélangés par la
// fonction de sortie XSH RR. Tout le calcul tient en entiers exacts sur des
// nombres flottants, sans BigInt ni lecture de l'environnement : même graine,
// même flux, même suite, sur tout moteur d'exécution. L'ordre des tirages
// fait partie du contrat (§ 15.5, point 3) : suivant consomme un tirage ;
// borne, un par essai, rejets compris ; melanger, ceux de ses n − 1 appels à
// borne.
// 6364136223846793005, le multiplicateur de pcg_basic.c, en deux mots.
const MULTIPLICATEUR_HAUT = 0x5851f42d;
const MULTIPLICATEUR_BAS = 0x4c957f2d;
const DEUX_32 = 0x100000000;
const MOT_MAX = 0xffffffff;
/**
* Flux (initseq de pcg32_srandom_r) réservés, un par usage : à graine égale,
* deux usages tirent deux suites différentes.
*/
export const FLUX = Object.freeze({ GRAINES: 1, RECHERCHE: 2, DEMO: 3 });
// Produit des 64 bits bas de (ahi:alo) × (bhi:blo). Chaque colonne reste sous
// 2^35, donc exacte en double ; la retenue se propage par division entière.
function mul64(ahi, alo, bhi, blo) {
const a0 = alo & 0xffff, a1 = alo >>> 16, a2 = ahi & 0xffff, a3 = ahi >>> 16;
const b0 = blo & 0xffff, b1 = blo >>> 16, b2 = bhi & 0xffff, b3 = bhi >>> 16;
let t = a0 * b0;
const r0 = t % 65536; t = Math.floor(t / 65536) + a0 * b1 + a1 * b0;
const r1 = t % 65536; t = Math.floor(t / 65536) + a0 * b2 + a1 * b1 + a2 * b0;
const r2 = t % 65536; t = Math.floor(t / 65536) + a0 * b3 + a1 * b2 + a2 * b1 + a3 * b0;
const r3 = t % 65536;
return [(r3 * 65536 + r2) >>> 0, (r1 * 65536 + r0) >>> 0];
}
// Écrit une valeur reçue dans un message d'erreur : un nombre tel quel, toute
// autre valeur suivie de son type.
function decrire(valeur) {
return typeof valeur === 'number' ? String(valeur) : `${String(valeur)} (${typeof valeur})`;
}
// Lève une RangeError nommant le paramètre quand la valeur n'est pas un
// entier de min à max.
function exigerEntier(nom, valeur, min, max) {
if (!Number.isInteger(valeur) || valeur < min || valeur > max) {
throw new RangeError(`${nom} : entier de ${min} à ${max} attendu, reçu ${decrire(valeur)}`);
}
}
/**
* Générateur PCG32 (XSH RR 64/32), état 64 bits porté en deux mots de 32 bits.
* Ensemencement identique à pcg32_srandom_r(initstate, initseq).
*
* L'objet rendu porte :
* - suivant() : entier non signé de 32 bits, pcg32_random_r ;
* - borne(n) : entier uniforme de [0, n), n entier de 1 à 2^32 − 1, par le
* rejet de pcg32_boundedrand_r ;
* - melanger(tab) : mélange tab en place et le rend, par la boucle de
* pcg32-demo.c — pour i de n à 2, échange l'élément borne(i) et
* l'élément i − 1.
*
* @param {number} graine initstate, entier de 0 à 2^32 − 1
* @param {number} flux initseq, entier de 0 à 2^32 − 1 (voir FLUX)
* @returns {{suivant: function(): number, borne: function(number): number,
* melanger: function(Array|Int32Array): (Array|Int32Array)}}
* @throws {RangeError} graine ou flux hors de cet intervalle ; borne(n) pour
* n hors du sien
*/
export function creerPcg32(graine, flux) {
exigerEntier('graine', graine, 0, MOT_MAX);
exigerEntier('flux', flux, 0, MOT_MAX);
// Incrément impair (flux << 1) | 1 : le bit 31 du flux passe au mot haut.
const incrementHaut = flux >>> 31;
const incrementBas = ((flux << 1) | 1) >>> 0;
let haut = 0;
let bas = 0;
// état ← état + (h:b), modulo 2^64. La somme des mots bas, sous 2^33, est
// exacte ; sa retenue passe au mot haut.
function ajouter(h, b) {
const somme = bas + b;
bas = somme >>> 0;
haut = (haut + h + (somme >= DEUX_32 ? 1 : 0)) >>> 0;
}
function suivant() {
const ancienHaut = haut;
const ancienBas = bas;
[haut, bas] = mul64(haut, bas, MULTIPLICATEUR_HAUT, MULTIPLICATEUR_BAS);
ajouter(incrementHaut, incrementBas);
// Sortie XSH RR de l'état d'avant le pas : les bits 27 à 58 de
// état ^ (état >> 18), tournés à droite du nombre que forment les 5 bits
// hauts de l'état.
const xHaut = ancienHaut ^ (ancienHaut >>> 18);
const xBas = ancienBas ^ ((ancienBas >>> 18) | (ancienHaut << 14));
const melange = (xBas >>> 27) | (xHaut << 5);
const rotation = ancienHaut >>> 27;
return ((melange >>> rotation) | (melange << (-rotation & 31))) >>> 0;
}
// Les tirages sous le seuil (2^32 − n) mod n sont rejetés : les
// 2^32 − seuil valeurs restantes, multiple de n, se répartissent également
// entre les n restes. La garde précède le seuil, qui vaudrait NaN pour
// n = 0. La boucle tire tant que r < seuil : pour un seuil entier, elle
// accepte les mêmes tirages que le « r >= seuil » de pcg32_boundedrand_r ;
// pour un seuil NaN, elle rend après un tirage, là où cette forme ne
// rendrait jamais. Une garde affaiblie fait ainsi échouer l'épreuve des
// bornes invalides au lieu de la figer : une boucle synchrone sans fin
// échappe au délai du lanceur.
function borne(n) {
exigerEntier('borne', n, 1, MOT_MAX);
const seuil = (DEUX_32 - n) % n;
let r;
do {
r = suivant();
} while (r < seuil);
return r % n;
}
// Un tableau de moins de deux éléments ne consomme aucun tirage.
function melanger(tab) {
for (let i = tab.length; i > 1; i -= 1) {
const j = borne(i);
const element = tab[j];
tab[j] = tab[i - 1];
tab[i - 1] = element;
}
return tab;
}
// pcg32_srandom_r : état nul, un pas, ajout de la graine, un pas.
suivant();
ajouter(0, graine);
suivant();
return { suivant, borne, melanger };
}

283
src/demo/prng.test.js Normal file
View file

@ -0,0 +1,283 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le vecteur de PCG32 vient de l'implémentation de référence, hors de ce
// dépôt : c'est un oracle, non un témoin (§ 14.11). Dépôt
// https://github.com/imneme/pcg-c-basic, commit
// bc39cd76ac3d541e618606bcc6e1e5ba5e5e6aa3 : pcg32-demo.c, compilé avec
// pcg_basic.c et exécuté sans argument, ensemence par
// pcg32_srandom_r(&rng, 42u, 54u) et imprime cinq tours de tirages.
// scripts/oracle/pcg32_reference.sh refait ces gestes ; TOUR_1 recopie le
// premier tour de sa sortie.
import assert from 'node:assert/strict';
import { performance } from 'node:perf_hooks';
import { describe, test } from '../../test/lanceur.js';
import { FLUX, creerPcg32 } from './prng.js';
const DEUX_32 = 2 ** 32;
// Premier tour de la démo, ligne par ligne, tel qu'elle l'imprime. Ses tirages
// se suivent dans cet ordre : six sorties de pcg32_random_r ; 65 pièces,
// pcg32_boundedrand_r(&rng, 2), imprimées H pour 1 et T pour 0 ; 33 dés,
// pcg32_boundedrand_r(&rng, 6) + 1 ; puis le mélange d'un paquet de 52 cartes
// numérotées de 0 à 51. La carte c s'imprime par sa valeur, de rang c / 4 en
// division entière dans « A23456789TJQK », puis sa couleur, de rang c % 4 dans
// « hcds ». La démo coupe le paquet en lignes de 22 cartes, jointes ici par
// une espace.
const TOUR_1 = {
sorties: '0xa15c02b7 0x7b47f409 0xba1d3330 0x83d2f293 0xbfa4784b 0xcbed606e',
pieces: 'HHTTTHTHHHTHTTTHHHHHTTTHHHTHTHTHTTHTTTHHHHHHTTTTHHTTTTTHTTTTTTTHT',
des: '3 4 1 1 2 2 3 2 4 3 2 4 3 3 5 2 3 1 3 1 5 1 4 1 5 6 4 6 6 2 6 3 3',
cartes:
'Qd Ks 6d 3s 3d 4c 3h Td Kc 5c Jh Kd Jd As 4s 4h Ad Th Ac Jc 7s Qs ' +
'2s 7h Kh 2d 6c Ah 4d Qh 9h 6s 5s 2c 9c Ts 8d 9s 3c 8c Js 5d 2h 6h ' +
'7d 8s 9d 5h 8h Qc 7c Tc',
};
// Une sortie de 32 bits, écrite comme la démo l'imprime (« 0x%08x »).
const hexa = (x) => `0x${x.toString(16).padStart(8, '0')}`;
// Une carte, écrite comme la démo l'imprime.
const carte = (c) => 'A23456789TJQK'[Math.floor(c / 4)] + 'hcds'[c % 4];
// Tire le premier tour de la démo, dans son ordre, et l'écrit comme elle.
function premierTour(rng) {
const sorties = Array.from({ length: 6 }, () => hexa(rng.suivant())).join(' ');
const pieces = Array.from(TOUR_1.pieces, () => (rng.borne(2) ? 'H' : 'T')).join('');
const des = TOUR_1.des.split(' ').map(() => rng.borne(6) + 1).join(' ');
const paquet = Array.from({ length: 52 }, (_, c) => c);
rng.melanger(paquet);
return { sorties, pieces, des, cartes: paquet.map(carte).join(' ') };
}
// Transcription de pcg32_srandom_r et pcg32_random_r en BigInt, modulo 2^64 :
// elle ne partage rien de l'arithmétique par mots de 32 bits de prng.js. Rend
// la fonction de tirage, déjà ensemencée.
const MASQUE_64 = (1n << 64n) - 1n;
const MASQUE_32 = (1n << 32n) - 1n;
function transcriptionBigInt(graine, flux) {
const increment = ((BigInt(flux) << 1n) | 1n) & MASQUE_64;
let etat = 0n;
function tirer() {
const ancien = etat;
etat = (ancien * 6364136223846793005n + increment) & MASQUE_64;
const melange = (((ancien >> 18n) ^ ancien) >> 27n) & MASQUE_32;
const rotation = ancien >> 59n;
return Number(((melange >> rotation) | (melange << ((32n - rotation) & 31n))) & MASQUE_32);
}
tirer();
etat = (etat + BigInt(graine)) & MASQUE_64;
tirer();
return tirer;
}
// Les valeurs que l'appel admet, ou refuse par une autre erreur qu'une
// RangeError. Recueillies puis comparées à [], elles sont toutes nommées en un
// seul échec.
function admisesATort(valeurs, appel) {
return valeurs.filter((valeur) => {
try {
appel(valeur);
return true;
} catch (erreur) {
return !(erreur instanceof RangeError);
}
});
}
describe('PCG32', () => {
test('rejoue le premier tour de la démo de référence (graine 42, flux 54)', () => {
assert.deepEqual(premierTour(creerPcg32(42, 54)), TOUR_1);
});
// Le vecteur n'exerce ni le mot haut de l'incrément, nul pour le flux 54,
// ni la retenue des additions sur 64 bits : celle de l'incrément 109 ne
// survient que pour un mot bas de produit d'au moins 2^32 − 109, une fois
// sur quarante millions de pas ; celle de la graine 42, ajoutée à l'état
// 109, jamais. Un flux d'au moins 2^31 porte le mot haut. Le flux 2^32 − 1
// déclenche la retenue de l'incrément à presque chaque pas, la graine
// 2^32 − 1 sous le flux 0 celle de l'ensemencement seule, et les deux
// ensemble les déclenchent toutes deux. Sur ces cas, le test confronte
// prng.js à la transcription BigInt. C'est une contre-vérification de la
// décomposition en mots de 32 bits, non un oracle (§ 14.11) : la
// transcription ne s'ancre que sur les six sorties du vecteur, qui
// n'exercent pas ces chemins, et elle suit la même lecture de pcg_basic.c
// que prng.js. Une erreur de lecture commune aux deux, comme un incrément
// calculé sur 32 bits, y resterait verte.
test('concorde avec une transcription BigInt de pcg_basic.c aux graines et flux extrêmes', () => {
const ancre = transcriptionBigInt(42, 54);
assert.equal(Array.from({ length: 6 }, () => hexa(ancre())).join(' '), TOUR_1.sorties);
const cas = [
[0, 0],
[0xffffffff, 0],
[0, 0x80000000],
[0x80000000, 0x80000000],
[0, 0xffffffff],
[0xffffffff, 0xffffffff],
[42, FLUX.GRAINES],
[42, FLUX.RECHERCHE],
[42, FLUX.DEMO],
];
const ecarts = [];
for (const [graine, flux] of cas) {
const rng = creerPcg32(graine, flux);
const reference = transcriptionBigInt(graine, flux);
for (let tirage = 0; tirage < 1000; tirage += 1) {
const [obtenu, attendu] = [rng.suivant(), reference()];
if (obtenu !== attendu) {
ecarts.push(`graine ${graine}, flux ${flux}, tirage ${tirage} : ${obtenu} au lieu de ${attendu}`);
break;
}
}
}
assert.deepEqual(ecarts, []);
});
// Chaque générateur porte son propre état et son propre incrément. Deux
// générateurs vivants à la fois, sur deux flux de FLUX, rendent chacun la
// suite qu'il rendrait seul. Les tirages alternent : un état ou un
// incrément rangé hors de l'instance, donc partagé, décale alors les deux
// suites. Les deux flux donnent deux suites distinctes, sans quoi un
// incrément partagé ne changerait aucun tirage.
test('deux générateurs de flux différents, tirés en alternance, gardent chacun sa suite', () => {
const seul = (flux) => {
const rng = creerPcg32(42, flux);
return Array.from({ length: 50 }, () => rng.suivant());
};
const attendu = [seul(FLUX.GRAINES), seul(FLUX.RECHERCHE)];
assert.notDeepEqual(attendu[0], attendu[1]);
const a = creerPcg32(42, FLUX.GRAINES);
const b = creerPcg32(42, FLUX.RECHERCHE);
const obtenu = [[], []];
for (let i = 0; i < 50; i += 1) {
obtenu[0].push(a.suivant());
obtenu[1].push(b.suivant());
}
assert.deepEqual(obtenu, attendu);
});
// Les bornes du vecteur, 2, 6 puis 52 à 2, ont un seuil sous 2^6 : leur
// rejet ne survient pratiquement jamais. Le test prend une borne de chaque
// côté de 2^31. Pour n = 2^31 + 1, 2^32 − n est sous n et le modulo n'agit
// pas : le seuil vaut 2^31 − 1, et près d'un tirage sur deux est rejeté.
// Pour n = 2^30 + 1, il agit : le seuil vaut 2^32 − 3n = 2^30 − 3, et un
// tirage sur quatre environ est rejeté. Une négation sur 32 bits signés,
// ~n + 1, ne vaut 2^32 − n qu'au-dessus de 2^31 : le seuil qu'elle donne
// n'est faux que sous 2^31, et seule la seconde borne le voit. borne
// consomme un tirage par rejet et un par résultat : les résultats attendus
// sont les tirages acceptés d'un jumeau, réduits modulo n, dans l'ordre.
test('borne rejette les tirages sous le seuil (2^32 − n) mod n, comme pcg32_boundedrand_r', () => {
const jumeau = creerPcg32(42, 54);
const tirages = Array.from({ length: 200 }, () => jumeau.suivant());
for (const [n, seuil] of [
[2 ** 31 + 1, 2 ** 31 - 1],
[2 ** 30 + 1, 2 ** 30 - 3],
]) {
assert.equal((DEUX_32 - n) % n, seuil);
const acceptes = tirages.filter((r) => r >= seuil);
assert.ok(acceptes.length > 0, `n = ${n} : aucun tirage accepté`);
assert.ok(acceptes.length < tirages.length, `n = ${n} : aucun tirage rejeté, le rejet reste inexercé`);
const rng = creerPcg32(42, 54);
assert.deepEqual(
{ n, resultats: acceptes.map(() => rng.borne(n)) },
{ n, resultats: acceptes.map((r) => r % n) },
);
}
});
// Le deuxième tirage du vecteur, 0x7b47f409, est exactement le seuil de
// n = 2^32 − 0x7b47f409 : n dépasse 2^31, donc (2^32 − n) mod n = 2^32 − n.
// Accepté, il revient tel quel, puisqu'il est sous n.
test('borne accepte un tirage égal au seuil', () => {
const rng = creerPcg32(42, 54);
rng.suivant();
assert.equal(rng.borne(DEUX_32 - 0x7b47f409), 0x7b47f409);
});
// Pour n = 2^32 − 0x7b47f409 − 1, le seuil vaut de même 2^32 − n, soit
// 0x7b47f40a, un de plus que le deuxième tirage du vecteur : borne rejette
// celui-ci et rend le troisième, 0xba1d3330, réduit modulo n. Avec
// « borne accepte un tirage égal au seuil », ce test fixe le seuil des deux
// côtés : un seuil calculé sur 2^32 − 1 au lieu de 2^32 accepterait le
// deuxième tirage et décalerait tous les suivants.
test('borne rejette un tirage juste sous le seuil', () => {
const rng = creerPcg32(42, 54);
rng.suivant();
const n = DEUX_32 - 0x7b47f409 - 1;
assert.equal(rng.borne(n), 0xba1d3330 % n);
});
test("borne(1) rend toujours 0, au prix d'un tirage comme pcg32_boundedrand_r", () => {
const rng = creerPcg32(42, 54);
const jumeau = creerPcg32(42, 54);
for (let i = 0; i < 1000; i += 1) {
assert.equal(rng.borne(1), 0);
jumeau.suivant();
}
assert.equal(rng.suivant(), jumeau.suivant());
});
// 2^32 − 1 est la plus grande borne de pcg32_boundedrand_r, dont le
// paramètre est un uint32_t. Son seuil vaut 1 : le premier tirage du
// vecteur, 0xa15c02b7, passe et revient tel quel, puisqu'il est sous n.
test('borne admet 2^32 − 1, la plus grande borne de pcg32_boundedrand_r', () => {
assert.equal(creerPcg32(42, 54).borne(DEUX_32 - 1), 0xa15c02b7);
});
test('borne refuse 0, et toute borne hors des entiers de 1 à 2^32 − 1', () => {
const rng = creerPcg32(42, 54);
const invalides = [0, -1, 1.5, DEUX_32, NaN, Infinity, '6', 6n, undefined];
assert.deepEqual(admisesATort(invalides, (n) => rng.borne(n)), []);
});
// La boucle de la démo tire borne(i) pour i de n à 2 : un tableau de moins
// de deux éléments ne consomme aucun tirage. Un tirage de trop en fin de
// mélange échappe au vecteur, dont le paquet clôt le premier tour ; le
// jumeau, qui tire les mêmes bornes, le voit au tirage suivant.
test('melanger suit la boucle de la démo, en place : borne(i) pour i de n à 2', () => {
const rng = creerPcg32(42, 54);
const jumeau = creerPcg32(42, 54);
const tab = [10, 20, 30, 40, 50];
assert.equal(rng.melanger(tab), tab);
for (let i = tab.length; i > 1; i -= 1) jumeau.borne(i);
rng.melanger([]);
rng.melanger([7]);
assert.equal(rng.suivant(), jumeau.suivant());
assert.deepEqual([...tab].sort((a, b) => a - b), [10, 20, 30, 40, 50]);
});
// Une graine hors de l'intervalle, tronquée en silence, désignerait une
// autre graine ; un flux omis deviendrait le flux 0.
test('creerPcg32 refuse une graine ou un flux hors des entiers de 0 à 2^32 − 1', () => {
const invalides = [-1, 1.5, DEUX_32, NaN, Infinity, '42', 42n, undefined];
assert.deepEqual(admisesATort(invalides, (graine) => creerPcg32(graine, 54)), []);
assert.deepEqual(admisesATort(invalides, (flux) => creerPcg32(42, flux)), []);
});
// Témoin, non oracle : changer un flux change chaque graine dérivée et
// chaque démonstration. Le test fait de ce changement un geste conscient.
test('FLUX attribue les flux 1, 2 et 3, figés', () => {
assert.deepEqual(FLUX, { GRAINES: 1, RECHERCHE: 2, DEMO: 3 });
assert.ok(Object.isFrozen(FLUX));
});
// La durée est affichée, jamais affirmée : la cible est sous une seconde,
// et une machine chargée la dépasse sans que le code soit en cause
// (§ 14.14). L'assertion porte sur les tirages, entiers non signés de 32
// bits ; x >>> 0 diffère de x pour toute autre valeur.
test('1 000 000 tirages : durée affichée, cible sous une seconde', () => {
const rng = creerPcg32(42, FLUX.DEMO);
let horsMot = 0;
const debut = performance.now();
for (let i = 0; i < 1_000_000; i += 1) {
const x = rng.suivant();
if (x !== x >>> 0) horsMot += 1;
}
const duree = performance.now() - debut;
console.log(`PCG32 : 1 000 000 tirages en ${duree.toFixed(1)} ms (cible : moins de 1 000 ms)`);
assert.equal(horsMot, 0);
});
});

View file

@ -0,0 +1,242 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuve de rendu du bandeau d'application (§ 8.4, § 18.6), dans un vrai
// moteur de rendu. Le test interroge le rendu, jamais la présence d'une
// classe (§ 14.2) : la visibilité de chaque élément, la boîte où la mise en
// page le pose et l'élément qu'atteint le centre de cette boîte, le style
// calculé, le texte, le titre de la fenêtre. Il ne lit aucun pixel : le
// contraste se calcule sur les couleurs calculées, qui ne sont les couleurs
// peintes que sans opacité partielle, filtre ni mode de fusion — ce qu'une
// épreuve vérifie dans l'ascendance de chaque texte (§ 14.5). Les épreuves de
// visibilité, de contraste et d'opacité passent par les deux thèmes : le
// protocole de débogage de Chromium (CDP) émule prefers-color-scheme.
//
// Le projet navigateur n'importe pas node:assert/strict : côté navigateur,
// Vite le remplace par un module qui lève une erreur à tout accès. Il emploie
// expect, et prend test et describe chez Vitest plutôt que dans
// test/lanceur.js, dont le repli vers node:test ne vaut que pour la série node.
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { cdp } from 'vitest/browser';
import { flushSync, mount, unmount } from 'svelte';
import App from './App.svelte';
import { VERSION } from '../version.genere.js';
const TRANSPARENT = 'rgba(0, 0, 0, 0)';
const THEMES = ['light', 'dark'];
// Composantes [r, g, b, a] d'une couleur calculée. Chromium la rend sous la
// forme « rgb(r, g, b) » ou « rgba(r, g, b, a) » quand la source est une
// couleur sRGB ; toute autre forme fait échouer l'épreuve plutôt que de la
// laisser passer sans mesure.
function composantes(couleur) {
const m = /^rgba?\((\d+), (\d+), (\d+)(?:, ([\d.]+))?\)$/.exec(couleur);
if (m === null) {
throw new Error(`couleur calculée illisible : ${couleur}`);
}
const alpha = m[4] === undefined ? 1 : Number(m[4]);
return [Number(m[1]), Number(m[2]), Number(m[3]), alpha];
}
// Luminance relative d'une couleur sRGB, au sens des WCAG.
function luminance([r, g, b]) {
const lineaire = (c) => {
const s = c / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * lineaire(r) + 0.7152 * lineaire(g) + 0.0722 * lineaire(b);
}
function contraste(a, b) {
const la = luminance(a);
const lb = luminance(b);
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}
// L'élément, puis chacun de ses ancêtres jusqu'à la racine du document.
function ascendance(element) {
const chaine = [];
for (let n = element; n !== null; n = n.parentElement) {
chaine.push(n);
}
return chaine;
}
// Fond peint sous un élément : le premier fond non transparent de son
// ascendance, ou null.
function fondPeint(element) {
for (const n of ascendance(element)) {
const fond = composantes(getComputedStyle(n).backgroundColor);
if (fond[3] !== 0) {
return fond;
}
}
return null;
}
// Éléments du sous-arbre qui portent eux-mêmes un nœud de texte non vide.
function porteursDeTexte(racine) {
return [racine, ...racine.querySelectorAll('*')].filter((el) =>
[...el.childNodes].some(
(n) => n.nodeType === Node.TEXT_NODE && n.textContent.trim() !== '',
),
);
}
// Nom lisible d'un élément dans un message d'échec.
function designation(element) {
const classe = element.getAttribute('class');
return classe ? `${element.localName}.${classe}` : element.localName;
}
// Exécute mesure() sous le thème demandé et rend sa valeur. L'émulation est
// levée ensuite, même quand mesure() échoue : le test suivant retrouve la
// préférence propre du navigateur.
async function sousTheme(theme, mesure) {
await cdp().send('Emulation.setEmulatedMedia', {
features: [{ name: 'prefers-color-scheme', value: theme }],
});
try {
expect(matchMedia('(prefers-color-scheme: dark)').matches, theme).toBe(
theme === 'dark',
);
return mesure();
} finally {
await cdp().send('Emulation.setEmulatedMedia', { features: [] });
}
}
let composant;
beforeEach(() => {
composant = mount(App, { target: document.body });
flushSync();
});
afterEach(() => {
unmount(composant);
});
function bandeau() {
return document.body.querySelector('header.bandeau');
}
// Le bandeau, puis chacun des éléments qui y portent un texte.
function bandeauEtTextes() {
return [...new Set([bandeau(), ...porteursDeTexte(bandeau())])];
}
describe("bandeau d'application", () => {
test('existe et porte la version affichée', () => {
expect(bandeau()).not.toBeNull();
expect(bandeau().textContent).toContain(VERSION.affichee);
});
test('le bandeau et chacun de ses textes sont visibles dans la fenêtre, dans les deux thèmes (§ 14.2, § 18.6)', async () => {
// Le texte, le titre et le style calculé se relisent sur un élément qui
// ne peint rien. L'épreuve interroge donc la mise en page : chaque élément
// est visible pour checkVisibility (display, visibility, opacité nulle,
// content-visibility), sa boîte est non vide et tient dans la fenêtre, et
// le point central de cette boîte atteint l'élément ou l'un de ses
// descendants — un élément recouvert, ou rogné par un ancêtre, laisse le
// point à un autre. elementFromPoint suit l'atteinte du pointeur : un
// élément en pointer-events: none n'y répond pas, et l'épreuve le refuse.
for (const theme of THEMES) {
await sousTheme(theme, () => {
const elements = bandeauEtTextes();
expect(elements.length).toBeGreaterThan(1);
for (const element of elements) {
const lieu = `${theme} : ${designation(element)}`;
expect(
element.checkVisibility({ opacityProperty: true, visibilityProperty: true }),
`${lieu} : checkVisibility`,
).toBe(true);
const boite = element.getBoundingClientRect();
expect(boite.width, `${lieu} : largeur`).toBeGreaterThan(0);
expect(boite.height, `${lieu} : hauteur`).toBeGreaterThan(0);
expect(boite.left, `${lieu} : bord gauche`).toBeGreaterThanOrEqual(0);
expect(boite.top, `${lieu} : bord haut`).toBeGreaterThanOrEqual(0);
expect(boite.right, `${lieu} : bord droit`).toBeLessThanOrEqual(innerWidth);
expect(boite.bottom, `${lieu} : bord bas`).toBeLessThanOrEqual(innerHeight);
const atteint = document.elementFromPoint(
boite.left + boite.width / 2,
boite.top + boite.height / 2,
);
expect(
element.contains(atteint),
`${lieu} : le centre de sa boîte atteint ${atteint === null ? 'aucun élément' : designation(atteint)}`,
).toBe(true);
}
});
}
});
test('porte la provenance de la construction', () => {
expect(import.meta.env.MODE).toBe('test');
expect(bandeau().textContent).toContain('épreuve');
});
test('le titre de la fenêtre porte le nom et la version affichée', () => {
expect(document.title).toBe(
`Gestion table tournante Libre — ${VERSION.affichee}`,
);
});
test('le texte et le fond du bandeau ont une couleur calculée', () => {
const style = getComputedStyle(bandeau());
expect(style.color).not.toBe(TRANSPARENT);
expect(style.backgroundColor).not.toBe(TRANSPARENT);
});
test('chaque texte du bandeau tient 4,5:1 sur un fond opaque, dans les deux thèmes (§ 14.5)', async () => {
const couleursParTheme = [];
for (const theme of THEMES) {
const couleurs = await sousTheme(theme, () => {
const textes = porteursDeTexte(bandeau());
expect(textes.length).toBeGreaterThan(0);
for (const element of textes) {
const lieu = `${theme} : ${designation(element)}`;
const texte = composantes(getComputedStyle(element).color);
const fond = fondPeint(element);
expect(fond, lieu).not.toBeNull();
expect(texte[3], lieu).toBe(1);
expect(fond[3], lieu).toBe(1);
expect(contraste(texte, fond), lieu).toBeGreaterThanOrEqual(4.5);
}
return [document.body, bandeau()].map((el) => {
const style = getComputedStyle(el);
return `${style.color} sur ${style.backgroundColor}`;
});
});
couleursParTheme.push(couleurs);
}
// Une émulation sans effet, ou un thème sombre qui ne redéfinit aucune
// couleur, ferait mesurer deux fois le thème clair.
expect(
couleursParTheme[1],
`mêmes couleurs dans les deux thèmes : ${couleursParTheme[0].join(' ; ')}`,
).not.toEqual(couleursParTheme[0]);
});
test("aucune opacité partielle, aucun filtre ni mode de fusion sous un texte, dans les deux thèmes (§ 14.5)", async () => {
// opacity et filter ne s'héritent pas : posés sur un ancêtre, ils changent
// le pixel peint sans changer le style calculé du texte. Chaque niveau de
// l'ascendance est donc relu, jusqu'à la racine du document.
for (const theme of THEMES) {
await sousTheme(theme, () => {
const textes = porteursDeTexte(bandeau());
expect(textes.length).toBeGreaterThan(0);
for (const element of textes) {
for (const n of ascendance(element)) {
const style = getComputedStyle(n);
const lieu = `${theme} : ${designation(n)} sous ${designation(element)}`;
expect(style.opacity, lieu).toBe('1');
expect(style.filter, lieu).toBe('none');
expect(style.backdropFilter, lieu).toBe('none');
expect(style.mixBlendMode, lieu).toBe('normal');
}
}
});
}
});
});

52
src/interface/App.svelte Normal file
View file

@ -0,0 +1,52 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Racine de l'interface. Le bandeau d'application, présent sur tout écran,
// porte le nom, la version affichée et la provenance de la construction ;
// le nom suivi de la version affichée devient aussi le titre de la fenêtre
// (§ 8.4, § 18.6). La provenance vient du mode de Vite, que pose la
// commande de construction.
import './jetons.css';
import { VERSION } from '../version.genere.js';
import { libelleProvenance, titreAvecVersion } from './libelles.js';
const titre = titreAvecVersion(VERSION.affichee);
const provenance = libelleProvenance(import.meta.env.MODE);
</script>
<svelte:head>
<title>{titre}</title>
</svelte:head>
<header class="bandeau">
<span class="titre">{titre}</span>
<span class="provenance">{provenance}</span>
</header>
<style>
:global(body) {
margin: 0;
background-color: var(--couleur-page-fond);
color: var(--couleur-page-texte);
font-family: var(--police-interface);
}
.bandeau {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--espace-3);
padding: var(--espace-2) var(--espace-4);
border-bottom: 1px solid var(--couleur-bandeau-filet);
background-color: var(--couleur-bandeau-fond);
color: var(--couleur-bandeau-texte);
font-size: var(--taille-texte-bandeau);
}
.provenance {
padding: 0 var(--espace-2);
border: 1px solid var(--couleur-bandeau-filet);
border-radius: var(--rayon-1);
}
</style>

42
src/interface/jetons.css Normal file
View file

@ -0,0 +1,42 @@
/* © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) */
/* Jetons de l'interface : couleurs, typographie, espacements, rayons. Le thème
suit prefers-color-scheme ; chaque thème redéfinit les mêmes jetons de
couleur, et les composants ne lisent que les jetons (§ 13.3).
Chaque couleur de texte tient au moins 4,5:1 sur le fond où elle est peinte,
et aucune n'emploie de canal alpha : la couleur relue dans le style calculé
est alors la couleur peinte (§ 14.5). */
:root {
color-scheme: light;
--couleur-page-fond: #f6f7f9;
--couleur-page-texte: #1c2128;
--couleur-bandeau-fond: #1f3a5f;
--couleur-bandeau-texte: #ffffff;
--couleur-bandeau-filet: #8fb3d9;
--police-interface: system-ui, sans-serif;
--taille-texte-bandeau: 0.9375rem;
--espace-1: 0.25rem;
--espace-2: 0.5rem;
--espace-3: 0.75rem;
--espace-4: 1rem;
--rayon-1: 0.25rem;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--couleur-page-fond: #121519;
--couleur-page-texte: #e3e6ea;
--couleur-bandeau-fond: #1d2c3f;
--couleur-bandeau-texte: #f2f4f7;
--couleur-bandeau-filet: #5b7fa6;
}
}

37
src/interface/libelles.js Normal file
View file

@ -0,0 +1,37 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Table des chaînes visibles de l'interface. Un composant n'écrit aucune
// chaîne visible en dur : il les lit ici, seul endroit qu'une traduction
// remplace (§ 14.6).
const NOM_APPLICATION = 'Gestion table tournante Libre';
// Mode de construction de Vite → provenance affichée dans le bandeau
// (§ 18.6). Une Map ne répond qu'aux clés qu'on y a posées : un mode nommé
// comme une propriété héritée d'Object ne rend pas une fonction.
const PROVENANCES = new Map([
['developpement', 'développement'],
['livraison', 'livraison'],
['documentation', 'documentation'],
['test', 'épreuve'],
]);
// Libellé de la provenance pour un mode de Vite. Un mode sans libellé, comme
// celui d'un « vite build » lancé sans --mode, s'affiche sous son propre nom.
export function libelleProvenance(mode) {
return PROVENANCES.get(mode) ?? mode;
}
// Vrai quand le mode a un libellé déclaré. Comparer libelleProvenance(mode) à
// mode ne le dit pas : « livraison » et « documentation » ont un libellé
// identique à leur nom.
export function aUnLibelle(mode) {
return PROVENANCES.has(mode);
}
// Nom de l'application suivi de la version affichée : texte du bandeau et
// titre de la fenêtre (§ 18.6).
export function titreAvecVersion(version) {
return `${NOM_APPLICATION} — ${version}`;
}

View file

@ -0,0 +1,50 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Chaque mode que pose un script npm (« --mode … » dans package.json) a son
// libellé de provenance, comme le mode test que pose Vitest ; un mode sans
// libellé s'affiche sous son propre nom plutôt que de laisser le bandeau muet
// sur la provenance.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from '../../test/lanceur.js';
import { aUnLibelle, libelleProvenance, titreAvecVersion } from './libelles.js';
// Modes nommés par les scripts de package.json, triés, sans doublon.
function modesDesScripts() {
const paquet = JSON.parse(
readFileSync(new URL('../../package.json', import.meta.url), 'utf8'),
);
const modes = new Set();
for (const commande of Object.values(paquet.scripts)) {
for (const [, mode] of commande.matchAll(/--mode[= ](\S+)/g)) {
modes.add(mode);
}
}
return [...modes].sort();
}
describe('libellés', () => {
test('libelleProvenance nomme les quatre modes de construction', () => {
assert.equal(libelleProvenance('developpement'), 'développement');
assert.equal(libelleProvenance('livraison'), 'livraison');
assert.equal(libelleProvenance('documentation'), 'documentation');
assert.equal(libelleProvenance('test'), 'épreuve');
});
test('chaque mode que pose un script npm a un libellé de provenance', () => {
const modes = modesDesScripts();
assert.ok(modes.length > 0, 'aucun « --mode » dans les scripts de package.json');
assert.deepEqual(modes.filter((mode) => !aUnLibelle(mode)), []);
});
test('libelleProvenance rend un mode sans libellé sous son propre nom', () => {
assert.equal(libelleProvenance('production'), 'production');
assert.equal(libelleProvenance('constructor'), 'constructor');
assert.equal(aUnLibelle('constructor'), false);
});
test('titreAvecVersion joint le nom et la version par un tiret cadratin', () => {
assert.equal(titreAvecVersion('X'), 'Gestion table tournante Libre — X');
});
});

8
src/main.js Normal file
View file

@ -0,0 +1,8 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Point d'entrée de la page : monte l'interface dans le corps du document.
import { mount } from 'svelte';
import App from './interface/App.svelte';
mount(App, { target: document.body });

153
src/moteur/classement.js Normal file
View file

@ -0,0 +1,153 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Classement des propositions (§ 5.7) : un ordre explicite, celui que
// l'interface écrit, et non le scalaire que descend la recherche. Cinq
// critères, chacun à minimiser, s'appliquent dans l'ordre de CRITERES ; deux
// propositions égales sur tous se départagent par identifiant croissant
// (§ 15.5, point 4), jamais par l'ordre reçu.
//
// L'excédent de collisions précède les collisions cumulées : un plan qui
// répartit six collisions sur six paires passe devant un plan qui en
// concentre quatre sur une seule (§ 5.4). Les paires distinctes ne sont pas
// une clé ; elles valent les cumulées moins l'excédent.
import { ecartsAuPlafondAPriori } from './manque.js';
/**
* Les critères du classement, dans leur ordre d'application :
* - ecartAuPlafondAPrioriMax : le plus grand écart au plafond a priori,
* plafond a priori − rencontres, sur toutes les personnes, sous le nom
* que lui donne le glossaire du § 5.5 ;
* - excedentCollisions et collisionsCumulees, tels que mesurer les rend ;
* - rencontresRepetees : les rencontres répétées que le moteur a choisies ;
* celles qu'imposent les réservations sont les mêmes pour toute
* proposition, et ne départagent rien (§ 5.4) ;
* - redondance : Σ_p r(p), la redondance d'appartenance sommée sur les
* personnes, que mesurer rend sous le nom totalRedondance.
*/
export const CRITERES = Object.freeze([
'ecartAuPlafondAPrioriMax',
'excedentCollisions',
'collisionsCumulees',
'rencontresRepetees',
'redondance',
]);
// Le critère qui lit le plafond a priori par personne : sauté quand il est
// inconnu (§ 17, point 4).
const CRITERE_A_PRIORI = 'ecartAuPlafondAPrioriMax';
// Le plus grand élément d'une liste ; null pour une liste vide.
function plusGrand(valeurs) {
let plus = null;
for (const valeur of valeurs) if (plus === null || valeur > plus) plus = valeur;
return plus;
}
// Valeur de chaque critère pour une proposition lue : { mesures, ecarts },
// ecarts étant ses écarts au plafond a priori. Chaque grandeur se lit dans
// la mesure ou dans manque.js, aucune ne se refait ici. La Map ne sert qu'à
// retrouver un critère par son nom ; CRITERES en fixe l'ordre.
const VALEUR = new Map([
['ecartAuPlafondAPrioriMax', ({ ecarts }) => plusGrand(ecarts)],
['excedentCollisions', ({ mesures }) => mesures.excedentCollisions],
['collisionsCumulees', ({ mesures }) => mesures.collisionsCumulees],
['rencontresRepetees', ({ mesures }) => mesures.rencontresRepetees.choisies],
['redondance', ({ mesures }) => mesures.totalRedondance],
]);
// Lève TypeError pour un identifiant qui n'est pas un entier, et RangeError
// pour un identifiant répété : deux entrées de même identifiant ne se
// départagent pas. L'ensemble ne sert qu'à retrouver un identifiant déjà vu.
function exigerIdentifiants(entrees) {
const vus = new Set();
for (const { id } of entrees) {
if (!Number.isInteger(id)) {
throw new TypeError(`proposition d'identifiant ${String(id)} : entier attendu`);
}
if (vus.has(id)) throw new RangeError(`proposition ${id} : identifiant répété`);
vus.add(id);
}
}
// Lève RangeError quand deux entrées mesurent des populations de tailles
// différentes : leurs chiffres ne se comparent pas. Les mesures ne portent
// aucun identifiant de personne : deux populations de même taille mais de
// personnes différentes passent, et l'appelant en répond (voir classer).
function exigerMemeTaille(entrees) {
const [premiere] = entrees;
for (const { id, mesures } of entrees) {
const taille = mesures.rencontres.length;
const attendue = premiere.mesures.rencontres.length;
if (taille !== attendue) {
throw new RangeError(
`proposition ${id} : ${taille} personnes mesurées, `
+ `${attendue} pour la proposition ${premiere.id}`,
);
}
}
}
// Ordre lexicographique de deux clés de même longueur. Sur une population
// vide, le plus grand écart au plafond a priori vaut null pour chaque entrée,
// toutes de même taille : deux null sont égaux.
function comparerCles(a, b) {
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
}
return 0;
}
/**
* Ordre des propositions (§ 5.7), la meilleure d'abord : critère par critère
* dans l'ordre de CRITERES, chacun croissant, puis identifiant croissant.
*
* Chaque entrée porte id, l'identifiant entier de la proposition ; mesures,
* telles que mesurer les rend ; plafondsAPriori, la liste de plafond.js, ou
* null quand le plafond a priori est inconnu (§ 17, point 4). Une seule
* entrée à plafond a priori inconnu fait sauter le premier critère pour
* toutes : critereSaute le nomme, et criteresAppliques s'ouvre sur le critère
* appliqué à sa place (§ 5.7). Sinon critereSaute vaut null et
* criteresAppliques reprend CRITERES.
*
* Toutes les entrées sont mesurées sur une même population : leurs chiffres
* ne se comparent qu'à cette condition. classer n'en contrôle que la taille,
* les mesures ne portant aucun identifiant de personne ; deux populations de
* même taille mais de personnes différentes, après une exclusion et un ajout
* (§ 9, § 12.6), passent sans bruit. Les identifiants sont uniques parmi les
* entrées, et rechercher numérote les propositions de chaque génération de 1
* à nombre : l'appelant qui accumule des générations (§ 5.7) les partitionne
* par population, et donne à chaque proposition un identifiant unique à
* travers les générations.
*
* Lève TypeError pour un identifiant qui n'est pas un entier, ou un
* plafondsAPriori qui n'est ni une liste ni null ; RangeError pour un
* identifiant répété, pour des mesures de populations de tailles
* différentes, ou pour un plafond a priori qui n'a pas une case par
* personne mesurée. Ne modifie pas ce qu'il reçoit.
*
* @param {Array<{id: number, mesures: import('./indicateurs.js').Mesures,
* plafondsAPriori: ArrayLike<number>|null}>} entrees
* @returns {{ordre: number[], criteresAppliques: string[], critereSaute: string|null}}
*/
export function classer(entrees) {
exigerIdentifiants(entrees);
exigerMemeTaille(entrees);
const lues = entrees.map(({ id, mesures, plafondsAPriori }) => ({
id,
mesures,
ecarts: ecartsAuPlafondAPriori(mesures, plafondsAPriori),
}));
const saute = lues.some(({ ecarts }) => ecarts === null);
const criteresAppliques = CRITERES.filter((critere) => !saute || critere !== CRITERE_A_PRIORI);
const classees = lues.map((lue) => ({
id: lue.id,
cle: criteresAppliques.map((critere) => VALEUR.get(critere)(lue)),
}));
classees.sort((a, b) => comparerCles(a.cle, b.cle) || a.id - b.id);
return {
ordre: classees.map(({ id }) => id),
criteresAppliques,
critereSaute: saute ? CRITERE_A_PRIORI : null,
};
}

View file

@ -0,0 +1,250 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du classement des propositions (§ 5.7). Une proposition
// synthétique ne porte que les champs que classer lit, pour trois personnes
// au plafond a priori de 8. Chaque ordre attendu est écrit en clair. Hors des
// épreuves où seul l'identifiant départage, chaque épreuve qui attend un
// ordre non vide en attend au moins un qui diffère de l'ordre des
// identifiants : un classement qui ne lirait aucun critère, et rangerait par
// identifiant, y échoue.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js';
import { CRITERES, classer } from './classement.js';
import { indexerPlan, normaliser } from './configuration.js';
import { mesurer } from './indicateurs.js';
// Les critères appliqués quand le premier est sauté.
const SANS_A_PRIORI = ['excedentCollisions', 'collisionsCumulees', 'rencontresRepetees', 'redondance'];
// Fige une valeur et, à toute profondeur, les objets et les tableaux qu'elle
// contient : une écriture du module éprouvé y lève TypeError.
function figer(valeur) {
if (typeof valeur !== 'object' || valeur === null || ArrayBuffer.isView(valeur)) return valeur;
for (const element of Object.values(valeur)) figer(element);
return Object.freeze(valeur);
}
// Proposition synthétique de trois personnes. rencontres fixe l'écart au
// plafond a priori de chacun, 8 − rencontres ; collisions est le triplet
// (cumulées, distinctes, excédent) du § 5.4 ; repetees et imposees comptent
// les rencontres répétées que le moteur a choisies et celles qu'imposent les
// réservations ; redondance est Σ_p r(p), le total que mesurer rend sous le
// nom totalRedondance. La liste des r(p) n'y figure pas : classer ne la lit
// pas.
function proposition(
id,
{
rencontres = [8, 8, 8],
collisions = [0, 0, 0],
repetees = 0,
imposees = 0,
redondance = 0,
} = {},
) {
const [collisionsCumulees, pairesDistinctes, excedentCollisions] = collisions;
return {
id,
mesures: {
rencontres,
collisionsCumulees,
pairesDistinctes,
excedentCollisions,
rencontresRepetees: { choisies: repetees, imposees },
totalRedondance: redondance,
},
plafondsAPriori: [8, 8, 8],
};
}
// La même proposition, son plafond a priori inconnu.
const inconnu = (entree) => ({ ...entree, plafondsAPriori: null });
// Les six ordres d'une liste de trois éléments.
const permutations = ([a, b, c]) => [[a, b, c], [a, c, b], [b, a, c], [b, c, a], [c, a, b], [c, b, a]];
describe('classer : les critères et leur ordre (§ 5.7)', () => {
test('CRITERES nomme les cinq critères dans leur ordre, et ne se modifie pas', () => {
assert.deepEqual(CRITERES, [
'ecartAuPlafondAPrioriMax',
'excedentCollisions',
'collisionsCumulees',
'rencontresRepetees',
'redondance',
]);
assert.ok(Object.isFrozen(CRITERES));
});
test("l'ordre de l'opérateur (§ 5.4) : l'excédent d'abord, le volume ensuite", () => {
// Les trois plans du § 5.4, au même écart au plafond a priori, en
// (cumulées, distinctes, excédent) : un collègue retrouvé quatre fois,
// deux collègues retrouvés deux fois chacun, six collègues retrouvés une
// fois chacun. Les rencontres répétées suivent : une paire réunie quatre
// fois, deux paires réunies deux fois, aucune.
const concentre = proposition(1, { collisions: [4, 1, 3], repetees: 1 });
const deuxFois = proposition(2, { collisions: [4, 2, 2], repetees: 2 });
const reparti = proposition(3, { collisions: [6, 6, 0] });
const entrees = [concentre, deuxFois, reparti];
for (const ordreRecu of permutations(entrees)) {
assert.deepEqual(classer(ordreRecu).ordre, [3, 2, 1]);
}
// La raison de la clé : un tri sur les seules collisions cumulées, ou sur
// les seules paires distinctes, met le plan réparti en dernier.
const triSur = (champ) =>
[...entrees].sort((a, b) => a.mesures[champ] - b.mesures[champ] || a.id - b.id).map(({ id }) => id);
assert.equal(triSur('collisionsCumulees').at(-1), 3);
assert.equal(triSur('pairesDistinctes').at(-1), 3);
});
test('chaque critère départage à son rang, avant tous les suivants', () => {
// Chaque proposition perd sur un seul critère et gagne sur tous les
// autres ; la proposition qui la suit perd sur le critère suivant. Seul
// l'ordre des critères les range, à l'envers de leurs identifiants.
const entrees = [
proposition(1, { rencontres: [7, 8, 8] }), // écart au plafond a priori 1
proposition(2, { collisions: [2, 1, 1], repetees: 1 }), // excédent 1
proposition(3, { collisions: [3, 3, 0] }), // 3 cumulées, excédent 0
proposition(4, { repetees: 1 }), // une rencontre répétée
proposition(5, { redondance: 1 }), // redondance 1
proposition(6),
];
assert.deepEqual(classer(figer(entrees)), {
ordre: [6, 5, 4, 3, 2, 1],
criteresAppliques: [...CRITERES],
critereSaute: null,
});
});
test('le premier critère est le plus grand écart au plafond a priori, ni sa somme ni son minimum', () => {
// Écarts au plafond a priori, personne par personne :
// 1 : 2, 0, 0 ; le plus grand 2, la somme 2, le minimum 0.
// 2 : 1, 1, 1 ; le plus grand 1, la somme 3, le minimum 1.
const entrees = [proposition(1, { rencontres: [6, 8, 8] }), proposition(2, { rencontres: [7, 7, 7] })];
assert.deepEqual(classer(entrees).ordre, [2, 1]);
});
test('seules les rencontres répétées que le moteur a choisies comptent (§ 5.4)', () => {
// 1 : une répétition choisie ; 2 : cinq qu'imposent les réservations.
const entrees = [proposition(1, { repetees: 1 }), proposition(2, { imposees: 5 })];
assert.deepEqual(classer(entrees).ordre, [2, 1]);
});
test('la redondance se lit sur totalRedondance, la somme que mesurer rend', () => {
// 1 : r(p) vaut 1, 1, 1, somme 3 et plus grand 1 ; 2 : r(p) vaut 2, 0,
// 0, somme 2 et plus grand 2. La somme range 2 devant 1, le plus grand
// ferait l'inverse.
const entrees = [proposition(1, { redondance: 3 }), proposition(2, { redondance: 2 })];
assert.deepEqual(classer(entrees).ordre, [2, 1]);
});
});
describe('classer : le critère sauté (§ 5.7, § 12.6)', () => {
test('un plafond a priori inconnu saute le premier critère pour toutes, et classer le nomme', () => {
// 1 : écart au plafond a priori 0, excédent 2.
// 2 : écart au plafond a priori 1, excédent 0.
const premiere = proposition(1, { collisions: [4, 2, 2], repetees: 2 });
const seconde = proposition(2, { rencontres: [7, 8, 8] });
assert.deepEqual(classer([premiere, seconde]), {
ordre: [1, 2],
criteresAppliques: [...CRITERES],
critereSaute: null,
});
// Un seul a priori inconnu suffit : l'excédent ouvre alors le classement
// de toutes, y compris de celle dont l'a priori est connu.
const attendu = { ordre: [2, 1], criteresAppliques: SANS_A_PRIORI, critereSaute: 'ecartAuPlafondAPrioriMax' };
assert.deepEqual(classer([premiere, inconnu(seconde)]), attendu);
assert.deepEqual(classer([inconnu(premiere), seconde]), attendu);
assert.deepEqual(classer([inconnu(premiere), inconnu(seconde)]), attendu);
});
});
describe('classer : le départage par identifiant (§ 15.5, point 4)', () => {
test("à égalité sur les cinq critères, l'identifiant croissant, quel que soit l'ordre reçu", () => {
const egales = [7, 3, 5].map((id) =>
proposition(id, { rencontres: [7, 8, 8], collisions: [2, 1, 1], repetees: 1, redondance: 1 }),
);
for (const ordreRecu of permutations(egales)) {
assert.deepEqual(classer(ordreRecu).ordre, [3, 5, 7]);
}
});
});
// Un plan de la variante « conflit inévitable » (§ 15.3), où A = {1, 5, 8,
// 10, 12}, B = {2, 4, 9, 11} et C = {3, 6, 7}. 1 et 5 restent ensemble aux
// quatre tours, avec un membre de B ; chaque autre table réunit un membre de
// A, de B et de C. Quatre collisions cumulées sur une seule paire.
const CONCENTRE = {
tables: [1, 2, 3, 4],
tours: [
[[1, 2, 5], [3, 4, 8], [6, 9, 10], [7, 11, 12]],
[[1, 4, 5], [6, 8, 9], [7, 10, 11], [2, 3, 12]],
[[1, 5, 9], [7, 8, 11], [2, 3, 10], [4, 6, 12]],
[[1, 5, 11], [2, 6, 8], [3, 4, 10], [7, 9, 12]],
],
reserves: [[], [], [], []],
};
describe('classer : petite démonstration, conflit inévitable (§ 14.10, § 15.3)', () => {
test('le plan qui répartit ses quatre collisions passe devant celui qui les concentre', () => {
const instance = normaliser(CATALOGUE.find(({ cle }) => cle === 'petite-conflit').construire());
const mesures = (plan) => mesurer(instance, indexerPlan(instance, plan));
const collisions = (m) => [m.collisionsCumulees, m.pairesDistinctes, m.excedentCollisions];
// Le plan parfait de la petite démonstration répartit les siennes : 10
// rencontre 12 au tour 1, 5 au tour 2, 8 au tour 3 et 1 au tour 4.
const concentre = mesures(CONCENTRE);
const reparti = mesures(PLAN_PARFAIT_PETITE);
assert.deepEqual(collisions(concentre), [4, 1, 3]);
assert.deepEqual(collisions(reparti), [4, 4, 0]);
// Nul ne rencontre plus de 8 personnes sur 11 (§ 15.3).
const huit = new Array(12).fill(8);
const entrees = [
{ id: 1, mesures: concentre, plafondsAPriori: huit },
{ id: 2, mesures: reparti, plafondsAPriori: huit },
];
assert.deepEqual(classer(entrees).ordre, [2, 1]);
// Le plafond a priori inconnu, l'excédent décide seul : 0 contre 3.
assert.deepEqual(classer(entrees.map(inconnu)), {
ordre: [2, 1],
criteresAppliques: SANS_A_PRIORI,
critereSaute: 'ecartAuPlafondAPrioriMax',
});
});
});
describe('classer : les entrées reçues', () => {
test("une liste vide rend un ordre vide, tous les critères appliqués", () => {
assert.deepEqual(classer([]), { ordre: [], criteresAppliques: [...CRITERES], critereSaute: null });
});
test("population vide, plafond a priori connu : le premier critère s'applique, sans rien départager", () => {
// Une population vide n'a pas de plus grand écart au plafond a priori ;
// ce n'est pas un plafond a priori inconnu, et rien n'est sauté.
const vide = (id) => ({ ...proposition(id, { rencontres: [] }), plafondsAPriori: [] });
assert.deepEqual(classer([vide(2), vide(1)]), {
ordre: [1, 2],
criteresAppliques: [...CRITERES],
critereSaute: null,
});
});
test('un identifiant répété lève RangeError ; un identifiant non entier, TypeError', () => {
assert.throws(() => classer([proposition(1), proposition(2), proposition(1)]), RangeError);
assert.throws(() => classer([proposition('1')]), TypeError);
assert.throws(() => classer([proposition(1.5)]), TypeError);
});
test("un plafond a priori undefined lève TypeError ; d'une autre longueur que les rencontres, RangeError", () => {
const sansAPriori = { ...proposition(2), plafondsAPriori: undefined };
assert.throws(() => classer([proposition(1), sansAPriori]), TypeError);
assert.throws(() => classer([{ ...proposition(1), plafondsAPriori: [8, 8] }]), RangeError);
});
test('des mesures de populations de tailles différentes lèvent RangeError', () => {
const quatre = {
...proposition(2, { rencontres: [8, 8, 8, 8] }),
plafondsAPriori: [8, 8, 8, 8],
};
assert.throws(() => classer([proposition(1), quatre]), RangeError);
});
});

558
src/moteur/configuration.js Normal file
View file

@ -0,0 +1,558 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Passage de la configuration, forme par identifiants, à l'instance indexée
// que consomme le moteur (§ 4, § 5.2), et conversion d'un plan entre ses deux
// formes (§ 8.9). Les formes sont décrites dans types.js.
//
// Ce module définit aussi, une seule fois pour tout le moteur (§ 13.2), ce
// que les autres modules lisent de ces formes : les valeurs du contrat —
// STATUT, LIBRE, RESERVE, SANS_GROUPE —, la garde de forme d'un plan indexé,
// le nombre de places d'un tour, et la répartition des personnes entre les
// trois populations du § 5.4. Les autres modules et leurs épreuves les
// importent, sans en redéfinir aucun.
//
// Trois familles de refus :
// - ErreurConfiguration, avec un code, pour ce qu'une saisie, un import ou un
// plan périmé peut porter : une donnée incohérente avec le reste de la
// configuration, ou une capacité, un nombre de tours, un tour désigné hors
// de son domaine ;
// - TypeError, quand la configuration sort de l'une des formes que garantit
// le code qui la bâtit — stockage, import, interface — et qu'aucune saisie
// ne produit : un identifiant de participant ou de table qui n'est pas un
// entier ≥ 1, une appartenance ni chaîne ni null, un exclu présent et non
// booléen, une portée ni « tous » ni « tour », des contraintes qui ne sont
// pas un objet de quatre booléens, une liste qui n'est pas un tableau. Ces
// formes sont les seules que normaliser contrôle par TypeError ; une valeur
// qui en sort est une faute de l'appelant, qu'aucune valeur par défaut ne
// masque ;
// - RangeError, pour un plan indexé qui n'a pas la forme de l'instance : seul
// le moteur produit cette forme, et l'écart y est une faute de code.
//
// Les champs que normaliser ne lit pas — nom, prénom, numéro de table — ne
// sont pas examinés. Aucune fonction ne modifie ce qu'elle reçoit.
import { ErreurConfiguration } from './erreurs.js';
/**
* Statuts d'un participant (§ 4.2), valeurs de instance.statut. normaliser
* les dérive des réservations ; aucun n'est déclaré. Un partiellement fixé
* compte parmi les mobiles dans les populations du § 5.4.
*/
export const STATUT = Object.freeze({ MOBILE: 0, PARTIELLEMENT_FIXE: 1, ANCRE: 2 });
/** Case de instance.fixe sans table imposée. */
export const LIBRE = -1;
/**
* Case d'un plan indexé : la réserve. Elle vaut LIBRE, et une rangée de
* instance.fixe se lit ainsi comme le plan qui assied chacun à ses seuls
* tours fixés et le laisse en réserve aux autres : le diagnostic mesure ce
* plan, le plafond a priori part de cet itinéraire.
*/
export const RESERVE = -1;
/** instance.groupe d'un participant sans appartenance. */
export const SANS_GROUPE = -1;
// Case d'un plan indexé qu'indexerPlan n'a pas encore remplie ; jamais rendue.
const NON_PLACE = -2;
// Plus grand entier que représente un Int32Array : une capacité au-delà s'y
// tronquerait sans bruit.
const ENTIER_32_MAX = 2 ** 31 - 1;
const NOMS_CONTRAINTES = [
'separerAppartenances',
'nouveauxVoisins',
'nouvelleTable',
'varierAppartenances',
];
// Valeur citée dans un message : une chaîne s'écrit entre guillemets, pour
// ne pas se lire comme le nombre qu'elle contient.
// Valeur citée dans un message : une chaîne entre guillemets, pour qu'elle
// ne se lise pas comme le nombre qu'elle contient ; toute autre valeur telle
// quelle.
export const decrire = (valeur) => (typeof valeur === 'string' ? JSON.stringify(valeur) : String(valeur));
function exigerListe(valeur, nom) {
if (!Array.isArray(valeur)) throw new TypeError(`configuration.${nom} : liste attendue`);
}
function exigerIdentifiant(id, quoi) {
if (!Number.isInteger(id) || id < 1) {
throw new TypeError(`${quoi} d'identifiant ${decrire(id)} : entier ≥ 1 attendu`);
}
}
function exigerAppartenance({ id, appartenance }) {
if (appartenance !== null && typeof appartenance !== 'string') {
throw new TypeError(
`participant ${id} : appartenance chaîne ou null attendue, reçu ${decrire(appartenance)}`,
);
}
}
// Vrai pour un participant exclu ; un champ exclu absent vaut false.
function estExclu({ id, exclu }) {
if (exclu === undefined) return false;
if (typeof exclu !== 'boolean') {
throw new TypeError(`participant ${id} : exclu booléen attendu, reçu ${decrire(exclu)}`);
}
return exclu;
}
// Copie des quatre contraintes : une modification ultérieure de la
// configuration n'atteint pas l'instance.
function copierContraintes(contraintes) {
if (typeof contraintes !== 'object' || contraintes === null) {
throw new TypeError('configuration.contraintes : objet attendu');
}
const copie = {};
for (const nom of NOMS_CONTRAINTES) {
if (typeof contraintes[nom] !== 'boolean') {
throw new TypeError(`contrainte ${nom} : booléen attendu, reçu ${decrire(contraintes[nom])}`);
}
copie[nom] = contraintes[nom];
}
return copie;
}
// Groupe de chaque participant présent, présents triés par identifiant
// croissant : un libellé reçoit le numéro de sa première apparition,
// SANS_GROUPE marque l'absence d'appartenance. L'égalité de chaînes fait le
// groupe. La Map ne sert qu'à retrouver un libellé déjà numéroté.
function numeroterGroupes(presents) {
const groupe = new Int32Array(presents.length);
const groupes = [];
const numeroDe = new Map();
for (let p = 0; p < presents.length; p += 1) {
const { appartenance } = presents[p];
if (appartenance === null) {
groupe[p] = SANS_GROUPE;
continue;
}
if (!numeroDe.has(appartenance)) {
numeroDe.set(appartenance, groupes.length);
groupes.push(appartenance);
}
groupe[p] = numeroDe.get(appartenance);
}
return { groupe, groupes };
}
// Tables imposées par les réservations : fixe[p * R + r] = index de table,
// LIBRE sinon. Chaque réservation est vérifiée dans sa forme — participant et
// table connus, tour dans 1..R —, exclus compris ; celle d'un exclu s'arrête
// là, suspendue, sans effet (§ 4.4). Une réservation qui répète la table déjà
// imposée au même tour ne change rien ; une autre table au même tour lève
// RESERVATION_CONFLIT.
function fixerReservations(reservations, contexte) {
const { connus, indexDe, indexTableDe, idsTables, N, R } = contexte;
const fixe = new Int32Array(N * R).fill(LIBRE);
for (let i = 0; i < reservations.length; i += 1) {
const { participant: id, table: idTable, portee, tour } = reservations[i];
if (!connus.has(id)) {
throw new ErreurConfiguration('RESERVATION_INCONNUE', { reservation: i, participant: id });
}
const t = indexTableDe.get(idTable);
if (t === undefined) {
throw new ErreurConfiguration('RESERVATION_INCONNUE', { reservation: i, table: idTable });
}
// Tours couverts, numérotés à partir de 0 : chacun des R tours pour la
// portée « tous », le tour désigné pour la portée « tour ».
let premier;
let dernier;
if (portee === 'tous') {
premier = 0;
dernier = R - 1;
} else if (portee === 'tour') {
if (!Number.isInteger(tour) || tour < 1 || tour > R) {
throw new ErreurConfiguration('RESERVATION_TOUR', { reservation: i, tour });
}
premier = tour - 1;
dernier = tour - 1;
} else {
throw new TypeError(
`réservation ${i} : portée ${decrire(portee)}, « tous » ou « tour » attendue`,
);
}
const p = indexDe.get(id);
if (p === undefined) continue;
for (let r = premier; r <= dernier; r += 1) {
const imposee = fixe[p * R + r];
if (imposee === LIBRE) {
fixe[p * R + r] = t;
} else if (imposee !== t) {
throw new ErreurConfiguration('RESERVATION_CONFLIT', {
participant: id,
tour: r + 1,
tables: [idsTables[imposee], idTable],
});
}
}
}
return fixe;
}
// Lève SURRESERVATION quand, à un tour, une table reçoit plus de personnes
// fixées que de sièges (§ 5.9). Le compte porte sur les personnes, lues dans
// fixe : une réservation répétée ne compte qu'une fois. Les tables se
// parcourent par index, chacune par tours croissants.
function verifierSurreservation(fixe, { N, T, R, capacite, idsTables }) {
const fixees = new Int32Array(T * R);
for (let p = 0; p < N; p += 1) {
for (let r = 0; r < R; r += 1) {
const t = fixe[p * R + r];
if (t !== LIBRE) fixees[t * R + r] += 1;
}
}
for (let t = 0; t < T; t += 1) {
for (let r = 0; r < R; r += 1) {
if (fixees[t * R + r] > capacite[t]) {
throw new ErreurConfiguration('SURRESERVATION', {
table: idsTables[t],
tour: r + 1,
reservees: fixees[t * R + r],
capacite: capacite[t],
});
}
}
}
}
// Statut de chaque participant, dérivé de fixe et non de la forme des
// réservations (§ 4.2) : ancré quand la même table est imposée à chacun des R
// tours, mobile quand aucune ne l'est, partiellement fixé sinon. Un ancré
// compte dans k et dans ancresParTable ; un partiellement fixé compte parmi
// les mobiles.
function deriverStatuts(fixe, N, T, R) {
const statut = new Uint8Array(N);
const ancresParTable = new Int32Array(T);
let k = 0;
for (let p = 0; p < N; p += 1) {
const premiere = fixe[p * R];
let memeTable = true;
let aucune = true;
for (let r = 0; r < R; r += 1) {
const t = fixe[p * R + r];
if (t !== premiere) memeTable = false;
if (t !== LIBRE) aucune = false;
}
if (aucune) {
statut[p] = STATUT.MOBILE;
} else if (memeTable) {
statut[p] = STATUT.ANCRE;
ancresParTable[premiere] += 1;
k += 1;
} else {
statut[p] = STATUT.PARTIELLEMENT_FIXE;
}
}
return { statut, ancresParTable, k };
}
/**
* Instance indexée d'une configuration (§ 4, § 5.2). Les participants exclus
* en sont absents (§ 4.4) : ni index ni groupe, et leurs réservations,
* suspendues, ne fixent rien et ne prennent aucun siège.
*
* Lève ErreurConfiguration sur la première règle enfreinte — participants,
* puis tables, nombre de tours, réservations une à une, surréservation :
* PARTICIPANT_DOUBLON, TABLE_DOUBLON, CAPACITE (non entière, < 2, ou au-delà
* d'un Int32Array), TOURS (R non entier ou < 1), RESERVATION_INCONNUE
* (participant ou table), RESERVATION_TOUR (tour absent ou hors de 1..R),
* RESERVATION_CONFLIT (une personne à deux tables d'un même tour),
* SURRESERVATION (plus de personnes fixées que de sièges à une table, à un
* tour). Une salle trop petite n'est pas refusée : nombrePlacesManquantes la
* chiffre, et la recherche la refuse (§ 5.9). Les détails désignent
* participants et tables par identifiant, les tours par leur numéro à partir
* de 1, et la réservation fautive par son rang dans
* configuration.reservations, à partir de 0.
*
* Lève TypeError sur les seules formes qu'énumère l'en-tête du module :
* identifiants de participant et de table, appartenance, exclu, portée,
* contraintes, listes. Une capacité, un nombre de tours ou un tour désigné
* hors de leur forme lèvent CAPACITE, TOURS ou RESERVATION_TOUR ; une
* réservation qui désigne un participant ou une table absents de la
* configuration lève RESERVATION_INCONNUE, quelle que soit la forme de la
* valeur qui les désigne.
*
* @param {import('./types.js').Configuration} configuration
* @returns {import('./types.js').Instance}
*/
export function normaliser(configuration) {
const { participants, tables, tours: R, reservations } = configuration;
exigerListe(participants, 'participants');
exigerListe(tables, 'tables');
exigerListe(reservations, 'reservations');
const contraintes = copierContraintes(configuration.contraintes);
// connus porte tous les identifiants, exclus compris : un doublon reste un
// doublon, et la réservation d'un exclu n'est pas inconnue.
const connus = new Set();
const presents = [];
const exclus = [];
for (const participant of participants) {
exigerIdentifiant(participant.id, 'participant');
if (connus.has(participant.id)) {
throw new ErreurConfiguration('PARTICIPANT_DOUBLON', { participant: participant.id });
}
connus.add(participant.id);
exigerAppartenance(participant);
if (estExclu(participant)) exclus.push(participant.id);
else presents.push(participant);
}
presents.sort((a, b) => a.id - b.id);
exclus.sort((a, b) => a - b);
const T = tables.length;
const idsTables = [];
const indexTableDe = new Map();
const capacite = new Int32Array(T);
for (let t = 0; t < T; t += 1) {
const { id, capacite: places } = tables[t];
exigerIdentifiant(id, 'table');
if (indexTableDe.has(id)) throw new ErreurConfiguration('TABLE_DOUBLON', { table: id });
if (!Number.isInteger(places) || places < 2 || places > ENTIER_32_MAX) {
throw new ErreurConfiguration('CAPACITE', { table: id, capacite: places });
}
idsTables.push(id);
indexTableDe.set(id, t);
capacite[t] = places;
}
if (!Number.isInteger(R) || R < 1) throw new ErreurConfiguration('TOURS', { tours: R });
const N = presents.length;
const ids = presents.map((participant) => participant.id);
const indexDe = new Map(ids.map((id, p) => [id, p]));
const { groupe, groupes } = numeroterGroupes(presents);
const fixe = fixerReservations(reservations, { connus, indexDe, indexTableDe, idsTables, N, R });
verifierSurreservation(fixe, { N, T, R, capacite, idsTables });
const { statut, ancresParTable, k } = deriverStatuts(fixe, N, T, R);
return {
N,
T,
R,
ids,
indexDe,
exclus: new Set(exclus),
idsTables,
indexTableDe,
capacite,
groupe,
groupes,
fixe,
statut,
ancresParTable,
k,
n: N - k,
contraintes,
};
}
/**
* Plan indexé d'un plan par identifiants : tableDe[p * R + r] = index de
* table, −1 pour la réserve. plan.tables déclare chaque table de l'instance
* une fois, et chaque liste se rattache à sa table par l'identifiant déclaré
* à la même position ; l'ordre des tables et celui des ids dans une liste
* sont indifférents. Un plan accepté revient donc de planDepuisIndex à
* l'identique, à ces deux ordres près.
*
* La forme indexée n'a pas de case pour un participant absent d'un tour,
* placé deux fois ou étranger à l'instance, ni pour une table que le plan ne
* déclare pas : au lieu de perdre l'écart, indexerPlan lève
* ErreurConfiguration — PLAN_TOURS (tours ou réserves en nombre ≠ R),
* PLAN_TABLE_INCONNUE, PLAN_TABLE_DOUBLON, PLAN_TABLE_ABSENTE (une table de
* l'instance que plan.tables ne déclare pas), PLAN_LISTES (un tour sans
* exactement une liste par table déclarée), PLAN_INCONNU, PLAN_EXCLU_PLACE
* (participant exclu de l'instance), PLAN_DOUBLE_PLACE (deux fois dans un
* tour, réserve comprise), PLAN_NON_ASSIS (ni à une table ni en réserve à un
* tour). Les capacités et les réservations ne sont pas examinées.
*
* @param {import('./types.js').Instance} instance
* @param {import('./types.js').Plan} plan
* @returns {Int32Array}
*/
export function indexerPlan(instance, plan) {
const { N, T, R, ids, idsTables, indexDe, indexTableDe, exclus } = instance;
if (plan.tours.length !== R || plan.reserves.length !== R) {
throw new ErreurConfiguration('PLAN_TOURS', {
attendu: R,
tours: plan.tours.length,
reserves: plan.reserves.length,
});
}
// Index de table de chaque liste, par position dans plan.tables.
const tableDeListe = [];
const declarees = new Set();
for (const id of plan.tables) {
const t = indexTableDe.get(id);
if (t === undefined) throw new ErreurConfiguration('PLAN_TABLE_INCONNUE', { table: id });
if (declarees.has(id)) throw new ErreurConfiguration('PLAN_TABLE_DOUBLON', { table: id });
declarees.add(id);
tableDeListe.push(t);
}
// Sans id inconnu ni doublon, une table manque exactement quand le plan en
// déclare moins que l'instance ; la première absente, dans l'ordre de
// l'instance, est nommée.
if (tableDeListe.length !== T) {
const absente = idsTables.find((id) => !declarees.has(id));
throw new ErreurConfiguration('PLAN_TABLE_ABSENTE', { table: absente });
}
const tableDe = new Int32Array(N * R).fill(NON_PLACE);
const placer = (id, r, t) => {
const p = indexDe.get(id);
if (p === undefined) {
const code = exclus.has(id) ? 'PLAN_EXCLU_PLACE' : 'PLAN_INCONNU';
throw new ErreurConfiguration(code, { participant: id, tour: r + 1 });
}
if (tableDe[p * R + r] !== NON_PLACE) {
throw new ErreurConfiguration('PLAN_DOUBLE_PLACE', { participant: id, tour: r + 1 });
}
tableDe[p * R + r] = t;
};
for (let r = 0; r < R; r += 1) {
const listes = plan.tours[r];
if (listes.length !== tableDeListe.length) {
throw new ErreurConfiguration('PLAN_LISTES', {
tour: r + 1,
listes: listes.length,
tables: tableDeListe.length,
});
}
for (let i = 0; i < listes.length; i += 1) {
for (const id of listes[i]) placer(id, r, tableDeListe[i]);
}
for (const id of plan.reserves[r]) placer(id, r, RESERVE);
for (let p = 0; p < N; p += 1) {
if (tableDe[p * R + r] === NON_PLACE) {
throw new ErreurConfiguration('PLAN_NON_ASSIS', { participant: ids[p], tour: r + 1 });
}
}
}
return tableDe;
}
/**
* La garde de forme d'un plan indexé, la seule du moteur : planDepuisIndex,
* mesurer et plafondsRealises l'appellent avant de lire une case. Lève
* RangeError quand tableDe n'a pas N × R cases, ou qu'une case n'est ni
* RESERVE ni un index de table entier de 0 à T − 1. Le message nomme alors
* le participant, par identifiant, le tour et la valeur de la première case
* fautive dans l'ordre des cases, p × R + r croissant ; une chaîne s'y cite
* entre guillemets, pour ne pas se lire comme le nombre qu'elle contient.
*
* Sans elle, une case de trop serait ignorée, et une chaîne numérique comme
* "0" assiérait la personne à la table qu'elle désigne là où une
* comparaison stricte à instance.fixe n'y reconnaîtrait pas sa réservation.
*
* @param {import('./types.js').Instance} instance
* @param {ArrayLike<number>} tableDe
*/
export function exigerPlanIndexe({ N, T, R, ids }, tableDe) {
if (tableDe.length !== N * R) {
throw new RangeError(`plan indexé de ${tableDe.length} cases, N × R = ${N * R} attendues`);
}
for (let i = 0; i < tableDe.length; i += 1) {
const t = tableDe[i];
if (t !== RESERVE && !(Number.isInteger(t) && t >= 0 && t < T)) {
throw new RangeError(
`plan indexé : participant ${ids[Math.floor(i / R)]}, tour ${(i % R) + 1}, `
+ `index de table ${decrire(t)} hors de −1..${T - 1}`,
);
}
}
}
/**
* Plan par identifiants d'un plan indexé : les tables dans l'ordre de
* l'instance, chaque liste de table et chaque réserve en ids croissants
* (§ 8.9). Lève ce que lève exigerPlanIndexe.
*
* @param {import('./types.js').Instance} instance
* @param {ArrayLike<number>} tableDe
* @returns {import('./types.js').Plan}
*/
export function planDepuisIndex(instance, tableDe) {
exigerPlanIndexe(instance, tableDe);
const { N, T, R, ids, idsTables } = instance;
const tours = [];
const reserves = [];
for (let r = 0; r < R; r += 1) {
const listes = Array.from({ length: T }, () => []);
const reserve = [];
// Les ids croissent avec l'index : parcourir les participants par index
// range chaque liste en ids croissants, sans tri.
for (let p = 0; p < N; p += 1) {
const t = tableDe[p * R + r];
if (t === RESERVE) reserve.push(ids[p]);
else listes[t].push(ids[p]);
}
tours.push(listes);
reserves.push(reserve);
}
return { tables: [...idsTables], tours, reserves };
}
/**
* Places qu'offre chaque tour : Σ c_t, la somme des capacités. Ni les
* participants ni les réservations n'y entrent. Le nombre de places
* manquantes et le diagnostic d'une salle tendue la lisent ici.
*
* @param {import('./types.js').Instance} instance T et capacite sont lus
* @returns {number}
*/
export function nombrePlaces({ T, capacite }) {
let places = 0;
for (let t = 0; t < T; t += 1) places += capacite[t];
return places;
}
/**
* Places qui manquent à chaque tour pour asseoir tous les participants :
* max(0, N − Σ c_t). La quantité égale max(0, n − Σ (c_t − a_t)) et ne
* dépend d'aucune réservation (§ 5.9) : un ancré retire une personne des
* mobiles et un siège de la capacité libre, un partiellement fixé reste
* parmi les mobiles et ne retire rien.
*
* @param {import('./types.js').Instance} instance
* @returns {number}
*/
export function nombrePlacesManquantes(instance) {
return Math.max(0, instance.N - nombrePlaces(instance));
}
/**
* Replie valeurs[p] sur les trois populations du § 5.4, en un passage, dans
* l'ordre canonique : chacun compte dans « tous », puis parmi les ancrés, ou
* parmi les mobiles, partiellement fixés compris. C'est le seul endroit qui
* range une personne dans sa population : les agrégats des indicateurs et
* les chiffres du manque le lisent tous deux.
*
* replier(cumul, valeur) rend le cumul suivant sans modifier celui qu'il
* reçoit : les trois populations partent du même initial, et une population
* sans membre le garde.
*
* @template C
* @param {ArrayLike<*>} valeurs une par personne, dans l'ordre de instance.ids
* @param {ArrayLike<number>} statut instance.statut
* @param {C} initial
* @param {function(C, *): C} replier
* @returns {{tous: C, mobiles: C, ancres: C}}
*/
export function replierParPopulation(valeurs, statut, initial, replier) {
let tous = initial;
let mobiles = initial;
let ancres = initial;
for (let p = 0; p < valeurs.length; p += 1) {
const valeur = valeurs[p];
tous = replier(tous, valeur);
if (statut[p] === STATUT.ANCRE) ancres = replier(ancres, valeur);
else mobiles = replier(mobiles, valeur);
}
return { tous, mobiles, ancres };
}

View file

@ -0,0 +1,895 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la configuration (§ 4, § 5.2, § 5.9) : les valeurs du contrat
// de données, l'instance indexée que rend normaliser et ses refus, le nombre
// de places et de places manquantes, la conversion d'un plan entre la forme
// par identifiants et la forme indexée, la garde de forme d'un plan indexé
// et la répartition des personnes entre les trois populations du § 5.4. Les
// identifiants diffèrent des index qu'ils reçoivent et des tables arrivent
// hors de l'ordre de leurs identifiants : confondre un identifiant et un index
// fait échouer une épreuve au lieu de passer inaperçu.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { ErreurAnnulee, ErreurConfiguration } from './erreurs.js';
import {
LIBRE,
RESERVE,
SANS_GROUPE,
STATUT,
exigerPlanIndexe,
indexerPlan,
nombrePlaces,
nombrePlacesManquantes,
normaliser,
planDepuisIndex,
replierParPopulation,
} from './configuration.js';
import { mesurer } from './indicateurs.js';
import { plafondsRealises } from './plafond.js';
const { MOBILE, PARTIELLEMENT_FIXE, ANCRE } = STATUT;
const SANS_CONTRAINTE = {
separerAppartenances: false,
nouveauxVoisins: false,
nouvelleTable: false,
varierAppartenances: false,
};
// Entiers de debut à fin, inclus.
const suite = (debut, fin) => Array.from({ length: fin - debut + 1 }, (_, i) => debut + i);
// Participants des identifiants donnés, sans appartenance.
const personnes = (ids) => ids.map((id) => ({ id, nom: `P${id}`, appartenance: null }));
const exclu = (id) => ({ id, nom: `P${id}`, appartenance: null, exclu: true });
// Tables des identifiants donnés, toutes de la même capacité, numérotées dans
// l'ordre fourni.
const tablesDe = (ids, capacite) => ids.map((id, i) => ({ id, numero: i + 1, capacite }));
const tous = (participant, table) => ({ participant, table, portee: 'tous' });
const auTour = (participant, table, tour) => ({ participant, table, portee: 'tour', tour });
// Libellé d'une valeur de cas : une chaîne se distingue du nombre qu'elle écrit.
const libelleDe = (valeur) => (typeof valeur === 'string' ? `« ${valeur} » en texte` : String(valeur));
const configuration = ({
participants,
tables,
tours,
reservations = [],
contraintes = SANS_CONTRAINTE,
}) => ({ participants, tables, tours, reservations, contraintes });
// Gèle une valeur et tout ce qu'elle contient : une écriture y lève
// TypeError, le module s'exécutant en mode strict.
function geler(valeur) {
if (typeof valeur === 'object' && valeur !== null) {
for (const enfant of Object.values(valeur)) geler(enfant);
Object.freeze(valeur);
}
return valeur;
}
// Écart entre ce que lève fonction et une ErreurConfiguration du code et des
// détails attendus ; null quand ils concordent.
function ecartDeRefus(fonction, code, details) {
try {
fonction();
} catch (erreur) {
if (!(erreur instanceof ErreurConfiguration)) {
return `${erreur?.name} au lieu d'une ErreurConfiguration : ${erreur?.message}`;
}
if (erreur.code !== code) return `code ${erreur.code} au lieu de ${code}`;
try {
assert.deepEqual(erreur.details, details);
} catch {
return `détails ${JSON.stringify(erreur.details)} au lieu de ${JSON.stringify(details)}`;
}
return null;
}
return 'aucun refus';
}
// Chaque cas [libellé, fonction, code, détails] dont le refus s'écarte de
// l'attendu, en une ligne lisible.
function ecartsDeRefus(cas) {
assert.ok(cas.length > 0, 'aucun cas examiné');
return cas
.map(([libelle, fonction, code, details]) => [libelle, ecartDeRefus(fonction, code, details)])
.filter(([, ecart]) => ecart !== null)
.map(([libelle, ecart]) => `${libelle} : ${ecart}`);
}
describe('valeurs du contrat de données (types.js)', () => {
test('STATUT, LIBRE, RESERVE et SANS_GROUPE valent ce que types.js écrit, et STATUT ne se modifie pas', () => {
// Seule épreuve qui écrit ces valeurs en clair : les autres les
// importent. LIBRE et RESERVE sont égales : une rangée de instance.fixe se
// lit comme le plan qui laisse en réserve chaque tour libre.
assert.deepEqual(STATUT, { MOBILE: 0, PARTIELLEMENT_FIXE: 1, ANCRE: 2 });
assert.ok(Object.isFrozen(STATUT));
assert.deepEqual([LIBRE, RESERVE, SANS_GROUPE], [-1, -1, -1]);
});
});
describe('normaliser : statuts (§ 4.2, § 4.3)', () => {
test('le statut se dérive du fait : ancré, partiellement fixé ou mobile', () => {
const instance = normaliser(configuration({
participants: personnes(suite(1, 9)),
tables: tablesDe([11, 12, 13, 14], 4),
tours: 3,
reservations: [
tous(1, 11),
auTour(2, 12, 3), auTour(2, 12, 1), auTour(2, 12, 2),
auTour(3, 13, 2),
auTour(4, 11, 1), auTour(4, 12, 2),
auTour(5, 13, 1), auTour(5, 13, 2), auTour(5, 14, 3),
auTour(7, 14, 1),
auTour(8, 14, 3),
auTour(9, 12, 1), auTour(9, 13, 2), auTour(9, 12, 3),
],
}));
// 1 : portée « tous » ; 2 : R tours désignés, même table ; 3 : le seul
// tour du milieu ; 4 : deux tours, deux tables ; 5 : chaque tour, deux
// tables ; 6 : aucune réservation ; 7 : le seul premier tour ; 8 : le
// seul dernier tour ; 9 : chaque tour, la même table au premier et au
// dernier, une autre entre les deux. Seuls 1 et 2 comptent dans k : un
// partiellement fixé compte parmi les mobiles.
assert.deepEqual(instance.statut, Uint8Array.from([
ANCRE, ANCRE,
PARTIELLEMENT_FIXE, PARTIELLEMENT_FIXE, PARTIELLEMENT_FIXE,
MOBILE,
PARTIELLEMENT_FIXE, PARTIELLEMENT_FIXE, PARTIELLEMENT_FIXE,
]));
assert.deepEqual(instance.fixe, Int32Array.from([
0, 0, 0,
1, 1, 1,
LIBRE, 2, LIBRE,
0, 1, LIBRE,
2, 2, 3,
LIBRE, LIBRE, LIBRE,
3, LIBRE, LIBRE,
LIBRE, LIBRE, 3,
1, 2, 1,
]));
assert.equal(instance.k, 2);
assert.equal(instance.n, 7);
assert.deepEqual(instance.ancresParTable, Int32Array.from([1, 1, 0, 0]));
// À R = 1, une réservation de tour désigné fixe chacun des R tours : ancré.
const unTour = normaliser(configuration({
participants: personnes([1, 2]),
tables: tablesDe([11, 12], 2),
tours: 1,
reservations: [auTour(1, 11, 1)],
}));
assert.deepEqual(unTour.statut, Uint8Array.from([ANCRE, MOBILE]));
assert.deepEqual(unTour.fixe, Int32Array.from([0, LIBRE]));
assert.equal(unTour.k, 1);
});
});
describe('normaliser : ancrés et instance réduite (§ 5.2)', () => {
test('k, n = N − k et ancresParTable sur quatre tables de 5 à un ancré chacune', () => {
const instance = normaliser(configuration({
participants: personnes(suite(1, 20)),
tables: tablesDe([11, 12, 13, 14], 5),
tours: 5,
reservations: [
tous(1, 11),
tous(6, 12),
...suite(1, 5).map((tour) => auTour(11, 13, tour)),
tous(16, 14),
// Partiellement fixé : compté parmi les mobiles.
auTour(2, 12, 3),
],
}));
assert.equal(instance.N, 20);
assert.equal(instance.T, 4);
assert.equal(instance.R, 5);
assert.equal(instance.k, 4);
assert.equal(instance.n, 16);
assert.deepEqual(instance.ancresParTable, Int32Array.from([1, 1, 1, 1]));
assert.equal(instance.statut[instance.indexDe.get(2)], PARTIELLEMENT_FIXE);
});
test('ancresParTable suit les index de table, dans l’ordre de configuration.tables', () => {
const instance = normaliser(configuration({
participants: personnes(suite(1, 8)),
tables: tablesDe([13, 11, 14, 12], 5),
tours: 2,
reservations: [tous(1, 12), tous(2, 13), tous(3, 12), tous(4, 14)],
}));
assert.deepEqual(instance.idsTables, [13, 11, 14, 12]);
assert.equal(instance.indexTableDe.get(12), 3);
assert.deepEqual(instance.capacite, Int32Array.from([5, 5, 5, 5]));
assert.deepEqual(instance.ancresParTable, Int32Array.from([1, 0, 1, 2]));
assert.equal(instance.k, 4);
});
});
describe('normaliser : exclusion (§ 4.4)', () => {
test('un exclu disparaît de ids, figure dans exclus, et sa réservation est suspendue', () => {
const instance = normaliser(configuration({
participants: [...personnes([1, 2]), exclu(3), ...personnes([4])],
tables: tablesDe([11, 12], 2),
tours: 2,
reservations: [tous(3, 12)],
}));
assert.equal(instance.N, 3);
assert.deepEqual(instance.ids, [1, 2, 4]);
assert.equal(instance.indexDe.has(3), false);
assert.deepEqual([...instance.exclus], [3]);
assert.equal(instance.k, 0);
assert.equal(instance.n, 3);
assert.deepEqual(instance.ancresParTable, Int32Array.from([0, 0]));
assert.deepEqual(instance.fixe, new Int32Array(6).fill(LIBRE));
assert.deepEqual(instance.statut, new Uint8Array(3).fill(MOBILE));
});
test('la réservation d’un exclu rend sa place : elle n’entre pas dans la surréservation', () => {
const participants = [...personnes([1, 2]), exclu(3)];
const avec = (liste) => configuration({
participants: liste,
tables: tablesDe([11, 12], 2),
tours: 2,
reservations: [tous(1, 11), tous(2, 11), tous(3, 11)],
});
const instance = normaliser(avec(participants));
assert.equal(instance.k, 2);
assert.deepEqual(instance.ancresParTable, Int32Array.from([2, 0]));
// Réintégré, le même participant fait déborder la table.
const reintegres = participants.map((participant) => ({ ...participant, exclu: false }));
assert.deepEqual(
ecartsDeRefus([[
'réintégration',
() => normaliser(avec(reintegres)),
'SURRESERVATION',
{ table: 11, tour: 1, reservees: 3, capacite: 2 },
]]),
[],
);
});
test('la réservation suspendue d’un exclu n’interrompt pas la lecture : celles qui la suivent s’appliquent', () => {
// En tête de liste, la réservation de l'exclu précède celles des présents :
// un saut qui sortirait de la boucle au lieu de passer à la suivante les
// perdrait toutes, sans bruit.
const instance = normaliser(configuration({
participants: [...personnes([1]), exclu(2)],
tables: tablesDe([11, 12], 2),
tours: 2,
reservations: [tous(2, 11), tous(1, 12)],
}));
assert.equal(instance.k, 1);
assert.deepEqual(instance.statut, Uint8Array.from([ANCRE]));
assert.deepEqual(instance.fixe, Int32Array.from([1, 1]));
assert.deepEqual(
ecartsDeRefus([[
'trois présents réservés à une table de 2, après l’exclu',
() => normaliser(configuration({
participants: [...personnes([1, 2, 3]), exclu(4)],
tables: tablesDe([11, 12], 2),
tours: 1,
reservations: [tous(4, 11), tous(1, 11), tous(2, 11), tous(3, 11)],
})),
'SURRESERVATION',
{ table: 11, tour: 1, reservees: 3, capacite: 2 },
]]),
[],
);
});
test('une réservation suspendue reste vérifiée dans sa forme, et n’a aucun effet', () => {
const avec = (reservations) => configuration({
participants: [...personnes([1, 2]), exclu(3)],
tables: tablesDe([11, 12], 2),
tours: 2,
reservations,
});
// Deux tables au même tour : sans effet, donc sans conflit.
assert.equal(normaliser(avec([tous(3, 11), auTour(3, 12, 1)])).k, 0);
assert.deepEqual(
ecartsDeRefus([
['table inconnue', () => normaliser(avec([tous(3, 99)])),
'RESERVATION_INCONNUE', { reservation: 0, table: 99 }],
['tour hors de 1..R', () => normaliser(avec([auTour(3, 11, 3)])),
'RESERVATION_TOUR', { reservation: 0, tour: 3 }],
]),
[],
);
});
});
describe('normaliser : appartenances', () => {
test('les groupes se numérotent par première apparition en ids croissants, quel que soit l’ordre fourni', () => {
const fournis = [
{ id: 9, nom: 'P9', appartenance: 'Y' },
{ id: 2, nom: 'P2', appartenance: 'Z' },
{ id: 5, nom: 'P5', appartenance: 'X' },
{ id: 7, nom: 'P7', appartenance: null },
{ id: 3, nom: 'P3', appartenance: 'W', exclu: true },
{ id: 4, nom: 'P4', appartenance: 'Z' },
{ id: 1, nom: 'P1', appartenance: 'Y' },
];
const ordres = [fournis, [...fournis].reverse(), [3, 6, 0, 5, 1, 4, 2].map((i) => fournis[i])];
assert.ok(ordres.length > 0, 'aucun ordre examiné');
for (const participants of ordres) {
const instance = normaliser(configuration({ participants, tables: tablesDe([11], 8), tours: 1 }));
assert.deepEqual(instance.ids, [1, 2, 4, 5, 7, 9]);
assert.deepEqual(instance.groupes, ['Y', 'Z', 'X']);
assert.deepEqual(instance.groupe, Int32Array.from([0, 1, 1, 2, SANS_GROUPE, 0]));
}
});
test('l’égalité de chaînes fait le groupe : normaliser ne réconcilie rien', () => {
// La chaîne vide est une appartenance comme une autre : seul null marque
// l'absence d'appartenance.
const participants = [
{ id: 1, nom: 'P1', appartenance: 'X' },
{ id: 2, nom: 'P2', appartenance: 'x' },
{ id: 3, nom: 'P3', appartenance: 'X ' },
{ id: 4, nom: 'P4', appartenance: 'X' },
{ id: 5, nom: 'P5', appartenance: '' },
{ id: 6, nom: 'P6', appartenance: null },
{ id: 7, nom: 'P7', appartenance: '' },
];
const instance = normaliser(configuration({ participants, tables: tablesDe([11], 8), tours: 1 }));
assert.deepEqual(instance.groupes, ['X', 'x', 'X ', '']);
assert.deepEqual(instance.groupe, Int32Array.from([0, 1, 2, 0, 3, SANS_GROUPE, 3]));
});
});
describe('normaliser : la configuration reçue', () => {
test('normaliser ne modifie pas la configuration reçue', () => {
const recue = geler(configuration({
participants: [...personnes([9, 2]), exclu(5), { id: 4, nom: 'P4', appartenance: 'X' }],
tables: tablesDe([30, 10], 4),
tours: 2,
reservations: [tous(9, 30), auTour(2, 10, 2)],
}));
const instance = normaliser(recue);
assert.deepEqual(instance.ids, [2, 4, 9]);
assert.deepEqual(recue.participants.map((participant) => participant.id), [9, 2, 5, 4]);
});
test('l’instance copie les contraintes au lieu de les partager', () => {
const contraintes = { ...SANS_CONTRAINTE, separerAppartenances: true };
const instance = normaliser(configuration({
participants: personnes([1, 2]),
tables: tablesDe([11], 2),
tours: 1,
contraintes,
}));
contraintes.separerAppartenances = false;
assert.deepEqual(instance.contraintes, { ...SANS_CONTRAINTE, separerAppartenances: true });
});
});
describe('normaliser : refus (§ 5.9, § 6.2)', () => {
// Trois participants, deux tables de 2, deux tours : chaque cas n'enfreint
// qu'une règle.
const base = configuration({
participants: personnes([1, 2, 3]),
tables: tablesDe([11, 12], 2),
tours: 2,
});
const avec = (modification) => () => normaliser({ ...base, ...modification });
const capacite12 = (capacite) => ({ tables: [...tablesDe([11], 2), { id: 12, numero: 2, capacite }] });
test('chaque code d’erreur, sur un cas', () => {
const cas = [
['un participant en double', avec({ participants: personnes([1, 2, 2]) }),
'PARTICIPANT_DOUBLON', { participant: 2 }],
['un participant en double, dont un exclu', avec({ participants: [...personnes([1, 2]), exclu(2)] }),
'PARTICIPANT_DOUBLON', { participant: 2 }],
['une table en double', avec({ tables: tablesDe([11, 12, 11], 2) }),
'TABLE_DOUBLON', { table: 11 }],
...[1, 0, -3, 2.5, '8', Number.NaN, 2 ** 31].map((capacite) => [
`une capacité ${libelleDe(capacite)}`, avec(capacite12(capacite)),
'CAPACITE', { table: 12, capacite },
]),
...[0, -1, 1.5, '2', Number.NaN].map((tours) => [
`${libelleDe(tours)} tours`, avec({ tours }), 'TOURS', { tours },
]),
// Le détail reservation est le rang dans configuration.reservations, à
// partir de 0 : la réservation fautive se place au rang 0, puis au rang 1.
// Un participant ou une table désignés par une valeur qui n'est pas un
// identifiant de la configuration sont inconnus, quelle qu'en soit la
// forme.
['un participant inconnu', avec({ reservations: [tous(9, 11)] }),
'RESERVATION_INCONNUE', { reservation: 0, participant: 9 }],
['un participant inconnu au rang 1', avec({ reservations: [tous(1, 11), tous(9, 11)] }),
'RESERVATION_INCONNUE', { reservation: 1, participant: 9 }],
['un participant désigné en texte', avec({ reservations: [tous('1', 11)] }),
'RESERVATION_INCONNUE', { reservation: 0, participant: '1' }],
['une table inconnue', avec({ reservations: [tous(1, 11), auTour(2, 99, 1)] }),
'RESERVATION_INCONNUE', { reservation: 1, table: 99 }],
['une table désignée en texte', avec({ reservations: [tous(1, '11')] }),
'RESERVATION_INCONNUE', { reservation: 0, table: '11' }],
['un tour désigné absent', avec({ reservations: [{ participant: 1, table: 11, portee: 'tour' }] }),
'RESERVATION_TOUR', { reservation: 0, tour: undefined }],
...[0, 3, 1.5, '1'].map((tour) => [
`le tour désigné ${libelleDe(tour)}`, avec({ reservations: [auTour(1, 11, tour)] }),
'RESERVATION_TOUR', { reservation: 0, tour },
]),
['le tour désigné 3 au rang 1', avec({ reservations: [tous(1, 11), auTour(2, 12, 3)] }),
'RESERVATION_TOUR', { reservation: 1, tour: 3 }],
['deux tables au même tour désigné', avec({ reservations: [auTour(1, 11, 1), auTour(1, 12, 1)] }),
'RESERVATION_CONFLIT', { participant: 1, tour: 1, tables: [11, 12] }],
['la portée « tous » et un tour désigné ailleurs', avec({ reservations: [tous(1, 12), auTour(1, 11, 2)] }),
'RESERVATION_CONFLIT', { participant: 1, tour: 2, tables: [12, 11] }],
// Le conflit se découvre aussi en traitant une portée « tous » : elle ne
// remplace jamais une table déjà imposée à l'un de ses tours.
['un tour désigné puis la portée « tous » ailleurs', avec({ reservations: [auTour(1, 11, 2), tous(1, 12)] }),
'RESERVATION_CONFLIT', { participant: 1, tour: 2, tables: [11, 12] }],
['deux portées « tous » à deux tables', avec({ reservations: [tous(1, 11), tous(1, 12)] }),
'RESERVATION_CONFLIT', { participant: 1, tour: 1, tables: [11, 12] }],
['trois réservés à une table de 2 au tour 2',
avec({ reservations: [tous(1, 11), tous(2, 11), auTour(3, 11, 2)] }),
'SURRESERVATION', { table: 11, tour: 2, reservees: 3, capacite: 2 }],
];
assert.deepEqual(ecartsDeRefus(cas), []);
});
test('une table entièrement gelée, k_t = c_t, est acceptée', () => {
const instance = normaliser({ ...base, reservations: [tous(1, 11), tous(2, 11)] });
assert.deepEqual(instance.ancresParTable, Int32Array.from([2, 0]));
});
test('une réservation redondante compte une personne, pas deux', () => {
const instance = normaliser({
...base,
reservations: [tous(1, 11), auTour(1, 11, 2), tous(1, 11), tous(2, 11)],
});
assert.equal(instance.k, 2);
assert.deepEqual(instance.ancresParTable, Int32Array.from([2, 0]));
});
test('SURRESERVATION se juge à chaque table et à chaque tour, contre la capacité de cette table', () => {
// Trois tables hors de l'ordre de leurs identifiants, de capacités
// distinctes, et T ≠ R. Cinq participants : la plus grande table, pleine
// plus un, les fixe tous, le dernier compris.
const tables = [
{ id: 13, numero: 1, capacite: 3 },
{ id: 11, numero: 2, capacite: 2 },
{ id: 12, numero: 3, capacite: 4 },
];
const tours = 2;
const fixer = (reservations) => () =>
normaliser(configuration({ participants: personnes(suite(1, 5)), tables, tours, reservations }));
// c_t + 1 personnes fixées à la table t, au tour r ou à chaque tour par la
// portée « tous » : refusé, au premier tour qui déborde.
const debordees = tables.flatMap(({ id, capacite }) => [
...suite(1, tours).map((tour) => [
`${capacite + 1} fixées à la table ${id} au tour ${tour}`,
fixer(suite(1, capacite + 1).map((p) => auTour(p, id, tour))),
'SURRESERVATION', { table: id, tour, reservees: capacite + 1, capacite },
]),
[`${capacite + 1} ancrées à la table ${id}`,
fixer(suite(1, capacite + 1).map((p) => tous(p, id))),
'SURRESERVATION', { table: id, tour: 1, reservees: capacite + 1, capacite }],
]);
assert.deepEqual(ecartsDeRefus(debordees), []);
// c_t personnes fixées remplissent la table sans la déborder : accepté.
const remplies = tables.flatMap(({ id, capacite }) => [
...suite(1, tours).map((tour) => [
`${capacite} fixées à la table ${id} au tour ${tour}`,
fixer(suite(1, capacite).map((p) => auTour(p, id, tour))),
]),
[`${capacite} ancrées à la table ${id}`, fixer(suite(1, capacite).map((p) => tous(p, id)))],
]);
assert.ok(remplies.length > 0, 'aucun cas examiné');
const refusees = [];
for (const [libelle, fonction] of remplies) {
try {
fonction();
} catch (erreur) {
refusees.push(`${libelle} : ${erreur?.message}`);
}
}
assert.deepEqual(refusees, []);
});
test('une capacité de 2³¹ − 1, le plus grand entier d’un Int32Array, est acceptée', () => {
const instance = normaliser({ ...base, ...capacite12(2 ** 31 - 1) });
assert.equal(instance.capacite[1], 2 ** 31 - 1);
});
test('une valeur hors du contrat de données lève TypeError', () => {
const cas = [
['identifiant de participant 0', { participants: personnes([0, 1]) }],
['identifiant de participant 1.5', { participants: personnes([1.5]) }],
['identifiant de participant en texte', { participants: personnes(['1']) }],
['identifiant de table 0', { tables: tablesDe([0, 12], 2) }],
['identifiant de table en texte', { tables: tablesDe(['11', 12], 2) }],
['appartenance absente', { participants: [{ id: 1, nom: 'P1' }] }],
['appartenance numérique', { participants: [{ id: 1, nom: 'P1', appartenance: 7 }] }],
...['oui', '', 0, null].map((valeur) => [
`exclusion ${libelleDe(valeur)}`,
{ participants: [{ id: 1, nom: 'P1', appartenance: null, exclu: valeur }] },
]),
['portée inconnue', { reservations: [{ participant: 1, table: 11, portee: 'chaque' }] }],
['contraintes absentes', { contraintes: undefined }],
['une contrainte absente', {
contraintes: { separerAppartenances: true, nouveauxVoisins: false, nouvelleTable: false },
}],
['une contrainte non booléenne', { contraintes: { ...SANS_CONTRAINTE, nouvelleTable: 1 } }],
// Une liste absente, puis une valeur qui n'est pas un tableau mais que le
// code parcourrait sans bruit : un ensemble s'itère, et un objet sans
// longueur ne fait tourner aucune boucle.
['participants absents', { participants: undefined }],
['participants en ensemble', { participants: new Set(personnes([1, 2])) }],
['tables absentes', { tables: undefined }],
['tables en objet', { tables: {} }],
['réservations absentes', { reservations: undefined }],
['réservations en objet', { reservations: {} }],
];
const ecarts = [];
for (const [libelle, modification] of cas) {
try {
normaliser({ ...base, ...modification });
ecarts.push(`${libelle} : aucun refus`);
} catch (erreur) {
if (!(erreur instanceof TypeError)) {
ecarts.push(`${libelle} : ${erreur?.name} au lieu de TypeError`);
}
}
}
assert.ok(cas.length > 0, 'aucun cas examiné');
assert.deepEqual(ecarts, []);
});
});
describe('nombrePlaces (§ 5.9)', () => {
test('somme les capacités : ni les participants, ni les exclus, ni les réservations ne la changent', () => {
const tables = [
{ id: 13, numero: 1, capacite: 3 },
{ id: 11, numero: 2, capacite: 2 },
{ id: 12, numero: 3, capacite: 7 },
];
const salle = (participants, reservations = []) =>
normaliser(configuration({ participants, tables, tours: 2, reservations }));
assert.equal(nombrePlaces(salle(personnes(suite(1, 4)))), 12);
assert.equal(nombrePlaces(salle(personnes(suite(1, 20)))), 12);
assert.equal(nombrePlaces(salle([], [])), 12);
assert.equal(
nombrePlaces(salle([...personnes(suite(1, 4)), exclu(5)], [tous(1, 13), auTour(2, 12, 2), tous(5, 11)])),
12,
);
});
});
describe('nombrePlacesManquantes (§ 5.9)', () => {
const salle = (reservations = [], participants = personnes(suite(1, 15))) =>
normaliser(configuration({ participants, tables: tablesDe([11, 12, 13], 4), tours: 2, reservations }));
// Places manquantes lues sur l'instance réduite du § 5.2 : n − Σ (c_t − a_t),
// l'opposé de l'écart Σ (c_t − k_t) − n du § 5.9.
function placesManquantesReduites(instance) {
let placesLibres = 0;
for (let t = 0; t < instance.T; t += 1) {
placesLibres += instance.capacite[t] - instance.ancresParTable[t];
}
return instance.n - placesLibres;
}
test('vaut 3 pour trois tables de 4 et quinze personnes, que normaliser accepte', () => {
assert.equal(nombrePlacesManquantes(salle()), 3);
});
test('ne change pas quand on ajoute ou déplace une réservation', () => {
const variantes = [
[],
[tous(1, 11)],
[tous(1, 12)],
[auTour(1, 11, 1)],
[auTour(1, 11, 1), auTour(1, 12, 2)],
[tous(1, 11), auTour(2, 12, 2), tous(3, 13), tous(4, 13)],
];
assert.ok(variantes.length > 0, 'aucune variante examinée');
for (const reservations of variantes) {
const instance = salle(reservations);
assert.equal(nombrePlacesManquantes(instance), 3, JSON.stringify(reservations));
assert.equal(placesManquantesReduites(instance), 3, JSON.stringify(reservations));
}
});
test('vaut 0, jamais un nombre négatif, quand la salle suffit', () => {
assert.equal(nombrePlacesManquantes(salle([], personnes(suite(1, 12)))), 0);
assert.equal(nombrePlacesManquantes(salle([], personnes(suite(1, 10)))), 0);
});
test('un exclu ne compte pas : sa réintégration fait manquer une place', () => {
const participants = [...personnes(suite(1, 12)), exclu(13)];
assert.equal(nombrePlacesManquantes(salle([], participants)), 0);
const reintegres = participants.map((participant) => ({ ...participant, exclu: false }));
assert.equal(nombrePlacesManquantes(salle([], reintegres)), 1);
});
});
describe('plans : forme par identifiants et forme indexée (§ 8.9)', () => {
// Quatre participants placés, fournis dans le désordre, un exclu ; trois
// tables hors de l'ordre de leurs identifiants ; deux tours. T ≠ R : la
// disposition p × R + r se distingue de p × T + r, et la longueur N × R de
// N × T.
function instancePlans() {
return normaliser(configuration({
participants: [...personnes([3, 8]), exclu(5), ...personnes([11, 6])],
tables: tablesDe([40, 20, 30], 2),
tours: 2,
}));
}
// tableDe[p × R + r], p dans l'ordre des ids [3, 6, 8, 11], et le plan par
// identifiants qui lui correspond. Chaque table reçoit quelqu'un, une table
// reste vide à un tour, et une liste de table comme une réserve portent
// deux ids, dont l'ordre croissant se voit.
const TABLE_DE = [
RESERVE, 0,
RESERVE, 1,
0, 2,
2, 1,
];
const PLAN = {
tables: [40, 20, 30],
tours: [[[8], [], [11]], [[3], [6, 11], [8]]],
reserves: [[3, 6], []],
};
test('planDepuisIndex rend des ids croissants par table et dans la réserve', () => {
const instance = instancePlans();
const plan = planDepuisIndex(instance, Int32Array.from(TABLE_DE));
assert.deepEqual(plan, PLAN);
// Une liste ordinaire, gelée, convient aussi et reste intacte.
assert.deepEqual(planDepuisIndex(instance, geler([...TABLE_DE])), PLAN);
const listes = [...plan.tours.flat(), ...plan.reserves];
assert.ok(listes.length > 0, 'aucune liste examinée');
for (const liste of listes) {
assert.deepEqual(liste, [...liste].sort((a, b) => a - b));
}
// Le plan rendu a sa propre liste de tables : la modifier laisse
// l'instance intacte.
plan.tables.reverse();
assert.deepEqual(instance.idsTables, [40, 20, 30]);
});
test('indexerPlan ∘ planDepuisIndex est l’identité sur chaque plan indexé de l’instance', () => {
const instance = instancePlans();
const cases = instance.N * instance.R;
// Chaque case prend une valeur de RESERVE, −1, à T − 1.
const valeurs = instance.T + 1;
let examines = 0;
for (let rang = 0; rang < valeurs ** cases; rang += 1) {
const tableDe = new Int32Array(cases);
for (let i = 0, reste = rang; i < cases; i += 1, reste = Math.floor(reste / valeurs)) {
tableDe[i] = RESERVE + (reste % valeurs);
}
const retour = indexerPlan(instance, planDepuisIndex(instance, tableDe));
if (retour.length !== tableDe.length || retour.some((valeur, i) => valeur !== tableDe[i])) {
assert.deepEqual(retour, tableDe);
}
examines += 1;
}
assert.equal(examines, 4 ** 8);
});
test('indexerPlan lit chaque table par son identifiant, dans l’ordre que le plan déclare', () => {
const instance = instancePlans();
assert.deepEqual(indexerPlan(instance, geler(structuredClone(PLAN))), Int32Array.from(TABLE_DE));
// Le même plan, ses tables déclarées dans un autre ordre, chaque liste et
// chaque réserve dans le désordre.
const permute = {
tables: [30, 40, 20],
tours: [[[11], [8], []], [[8], [3], [11, 6]]],
reserves: [[6, 3], []],
};
const tableDe = indexerPlan(instance, geler(permute));
assert.deepEqual(tableDe, Int32Array.from(TABLE_DE));
assert.deepEqual(planDepuisIndex(instance, tableDe), PLAN);
});
test('indexerPlan refuse ce que la forme indexée ne représente pas', () => {
const instance = instancePlans();
const variante = (modifier) => () => {
const plan = structuredClone(PLAN);
modifier(plan);
return indexerPlan(instance, plan);
};
const cas = [
['un participant inconnu', variante((plan) => plan.tours[0][0].push(99)),
'PLAN_INCONNU', { participant: 99, tour: 1 }],
['un participant inconnu en réserve', variante((plan) => plan.reserves[1].push(99)),
'PLAN_INCONNU', { participant: 99, tour: 2 }],
['un participant exclu', variante((plan) => plan.tours[1][1].unshift(5)),
'PLAN_EXCLU_PLACE', { participant: 5, tour: 2 }],
['un participant exclu en réserve', variante((plan) => plan.reserves[0].push(5)),
'PLAN_EXCLU_PLACE', { participant: 5, tour: 1 }],
['une table inconnue', variante((plan) => { plan.tables[1] = 50; }),
'PLAN_TABLE_INCONNUE', { table: 50 }],
['une table en double', variante((plan) => { plan.tables[1] = 30; }),
'PLAN_TABLE_DOUBLON', { table: 30 }],
// La table 30 retirée du plan, ses occupants passés en réserve : le plan
// reste cohérent avec lui-même, mais ne reprend plus chaque table de
// l'instance.
['une table non déclarée', variante((plan) => {
plan.tables.pop();
plan.tours.forEach((listes, r) => plan.reserves[r].push(...listes.pop()));
}), 'PLAN_TABLE_ABSENTE', { table: 30 }],
// Aucune table déclarée, chacun en réserve : des trois absentes, la
// première dans l'ordre de l'instance est nommée.
['aucune table déclarée', variante((plan) => {
plan.tables = [];
plan.tours = [[], []];
plan.reserves = [[3, 6, 8, 11], [3, 6, 8, 11]];
}), 'PLAN_TABLE_ABSENTE', { table: 40 }],
['un tour de moins', variante((plan) => plan.tours.pop()),
'PLAN_TOURS', { attendu: 2, tours: 1, reserves: 2 }],
['un tour de trop', variante((plan) => plan.tours.push([[], [], []])),
'PLAN_TOURS', { attendu: 2, tours: 3, reserves: 2 }],
['une réserve de trop', variante((plan) => plan.reserves.push([])),
'PLAN_TOURS', { attendu: 2, tours: 2, reserves: 3 }],
['une liste de table de trop', variante((plan) => plan.tours[1].push([])),
'PLAN_LISTES', { tour: 2, listes: 4, tables: 3 }],
['une liste de table de moins', variante((plan) => plan.tours[1].pop()),
'PLAN_LISTES', { tour: 2, listes: 2, tables: 3 }],
['assis et en réserve', variante((plan) => plan.reserves[0].unshift(8)),
'PLAN_DOUBLE_PLACE', { participant: 8, tour: 1 }],
['deux fois à une table', variante((plan) => plan.tours[1][1].push(6)),
'PLAN_DOUBLE_PLACE', { participant: 6, tour: 2 }],
['à deux tables d’un même tour', variante((plan) => plan.tours[0][1].push(8)),
'PLAN_DOUBLE_PLACE', { participant: 8, tour: 1 }],
['deux fois en réserve', variante((plan) => plan.reserves[0].push(3)),
'PLAN_DOUBLE_PLACE', { participant: 3, tour: 1 }],
['absent d’un tour', variante((plan) => plan.reserves[0].pop()),
'PLAN_NON_ASSIS', { participant: 6, tour: 1 }],
];
assert.deepEqual(ecartsDeRefus(cas), []);
});
test('indexerPlan refuse quiconque manque à un tour : chaque participant, à chaque tour', () => {
const instance = instancePlans();
// Chaque id de l'instance, du premier index au dernier, retiré de la
// liste qui le porte à ce tour, table ou réserve.
const cas = [3, 6, 8, 11].flatMap((id) => [1, 2].map((tour) => [
`${id} retiré du tour ${tour}`,
() => {
const plan = structuredClone(PLAN);
for (const liste of [...plan.tours[tour - 1], plan.reserves[tour - 1]]) {
if (liste.includes(id)) liste.splice(liste.indexOf(id), 1);
}
return indexerPlan(instance, plan);
},
'PLAN_NON_ASSIS',
{ participant: id, tour },
]));
assert.deepEqual(ecartsDeRefus(cas), []);
});
// TABLE_DE dont les cases nommées reçoivent une autre valeur : fautes est
// une liste de couples [case, valeur].
const avecFautes = (fautes) => {
const tableDe = [...TABLE_DE];
for (const [i, valeur] of fautes) tableDe[i] = valeur;
return tableDe;
};
// Plans indexés hors de la forme de l'instance, et le message qui les
// refuse. Les ids [3, 6, 8, 11] occupent les index 0 à 3 ; la case p × 2 + r
// est celle de l'index p au tour r + 1. Trois tables : un index de table va
// de 0 à 2. Une chaîne numérique n'est pas un index, et se cite entre
// guillemets. Deux cases fautives : la première dans l'ordre des cases est
// nommée, la case 1 (3 au tour 2) avant la case 2 (6 au tour 1).
const FAUTIFS = [
['longueur N × R − 1', TABLE_DE.slice(1), 'plan indexé de 7 cases, N × R = 8 attendues'],
['longueur N × R + 1', [...TABLE_DE, RESERVE], 'plan indexé de 9 cases, N × R = 8 attendues'],
['longueur N × T', Array(12).fill(RESERVE), 'plan indexé de 12 cases, N × R = 8 attendues'],
['index de table T', avecFautes([[0, 3]]), 'participant 3, tour 1, index de table 3'],
['index −2', avecFautes([[3, -2]]), 'participant 6, tour 2, index de table -2'],
['index non entier', avecFautes([[5, 0.5]]), 'participant 8, tour 2, index de table 0.5'],
['index null', avecFautes([[4, null]]), 'participant 8, tour 1, index de table null'],
['chaîne "0"', avecFautes([[6, '0']]), 'participant 11, tour 1, index de table "0"'],
['chaîne "-1"', avecFautes([[7, '-1']]), 'participant 11, tour 2, index de table "-1"'],
['deux cases fautives', avecFautes([[2, 9], [1, 9]]), 'participant 3, tour 2, index de table 9'],
].map(([libelle, tableDe, message]) => [
libelle,
tableDe,
message.startsWith('plan indexé de') ? message : `plan indexé : ${message} hors de −1..2`,
]);
// « Nom : message » de l'erreur que lève fonction, « aucun refus » sinon.
function erreurDe(fonction) {
try {
fonction();
} catch (erreur) {
return `${erreur?.name} : ${erreur?.message}`;
}
return 'aucun refus';
}
test('exigerPlanIndexe accepte un plan indexé de la forme de l’instance, Int32Array ou liste, sans le modifier', () => {
const instance = instancePlans();
assert.equal(exigerPlanIndexe(instance, Int32Array.from(TABLE_DE)), undefined);
assert.equal(exigerPlanIndexe(instance, geler([...TABLE_DE])), undefined);
});
test('exigerPlanIndexe refuse un plan indexé hors forme : RangeError qui nomme le participant, le tour et la valeur de la première case fautive', () => {
const instance = instancePlans();
assert.ok(FAUTIFS.length > 0, 'aucune variante examinée');
const ecarts = [];
for (const [libelle, tableDe, message] of FAUTIFS) {
const recue = erreurDe(() => exigerPlanIndexe(instance, tableDe));
const attendue = `RangeError : ${message}`;
if (recue !== attendue) ecarts.push(`${libelle} : « ${recue} » au lieu de « ${attendue} »`);
}
assert.deepEqual(ecarts, []);
});
test('une seule garde : planDepuisIndex, mesurer et plafondsRealises lèvent l’erreur de exigerPlanIndexe, au même message', () => {
const instance = instancePlans();
const appelants = [
['planDepuisIndex', (tableDe) => planDepuisIndex(instance, tableDe)],
['mesurer', (tableDe) => mesurer(instance, tableDe)],
['plafondsRealises', (tableDe) => plafondsRealises(instance, tableDe)],
];
const ecarts = [];
let confrontees = 0;
for (const [libelle, tableDe] of FAUTIFS) {
const attendue = erreurDe(() => exigerPlanIndexe(instance, tableDe));
for (const [nom, appeler] of appelants) {
confrontees += 1;
const recue = erreurDe(() => appeler(tableDe));
if (recue !== attendue) ecarts.push(`${nom}, ${libelle} : « ${recue} » au lieu de « ${attendue} »`);
}
}
assert.equal(confrontees, 3 * FAUTIFS.length);
assert.deepEqual(ecarts, []);
});
});
describe('replierParPopulation (§ 5.4)', () => {
test('chacun compte dans tous, puis parmi les ancrés ou parmi les mobiles, partiellement fixés compris, dans l’ordre canonique', () => {
const statut = Uint8Array.from([ANCRE, MOBILE, PARTIELLEMENT_FIXE, ANCRE, MOBILE, PARTIELLEMENT_FIXE]);
const enListe = (cumul, valeur) => [...cumul, valeur];
assert.deepEqual(replierParPopulation([10, 11, 12, 13, 14, 15], statut, [], enListe), {
tous: [10, 11, 12, 13, 14, 15],
mobiles: [11, 12, 14, 15],
ancres: [10, 13],
});
});
test('les trois populations partent de la valeur initiale ; une population sans membre la garde', () => {
const plusGrand = (cumul, valeur) => (cumul === null || valeur > cumul ? valeur : cumul);
assert.deepEqual(
replierParPopulation([4, 9], Uint8Array.from([MOBILE, PARTIELLEMENT_FIXE]), null, plusGrand),
{ tous: 9, mobiles: 9, ancres: null },
);
assert.deepEqual(
replierParPopulation([3], Uint8Array.from([ANCRE]), null, plusGrand),
{ tous: 3, mobiles: null, ancres: 3 },
);
assert.deepEqual(
replierParPopulation([], new Uint8Array(0), 'vide', plusGrand),
{ tous: 'vide', mobiles: 'vide', ancres: 'vide' },
);
});
});
describe('erreurs du moteur', () => {
test('ErreurConfiguration porte son code et ses détails', () => {
const erreur = new ErreurConfiguration('CAPACITE', { table: 12, capacite: 1 });
assert.ok(erreur instanceof Error);
assert.equal(erreur.name, 'ErreurConfiguration');
assert.equal(erreur.code, 'CAPACITE');
assert.deepEqual(erreur.details, { table: 12, capacite: 1 });
assert.match(erreur.message, /CAPACITE/);
assert.deepEqual(new ErreurConfiguration('TOURS').details, {});
});
test('ErreurAnnulee est une Error qui porte son nom', () => {
const erreur = new ErreurAnnulee();
assert.ok(erreur instanceof Error);
assert.equal(erreur.name, 'ErreurAnnulee');
});
});

285
src/moteur/diagnostic.js Normal file
View file

@ -0,0 +1,285 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Diagnostic d'une configuration, avant toute recherche (§ 5.6) : ce que la
// configuration impose à tout plan, chiffré. Un plancher est une borne
// inférieure démontrée sur les plans que produit la recherche, ceux qui
// assoient chacun à chaque tour et honorent les réservations ; un plan édité
// à la main qui laisse quelqu'un en réserve (§ 12.6) peut mesurer moins. Un
// plancher s'écrit là où le diagnostic sait le prouver, et vaut null
// ailleurs, jamais 0 par défaut.
//
// Les quantités que le moteur calcule déjà viennent de leur unique
// implémentation (§ 13.2) : nombre de places et places manquantes de
// configuration.js, plafond a priori de plafond.js, retours imposés de la
// mesure d'indicateurs.js. Le diagnostic ne modifie pas la configuration et
// lève ce que normaliser lève.
/**
* @typedef {Object} PlancherCollisions
* @property {string} groupe libellé de l'appartenance
* @property {number} effectif membres présents
* @property {number} plancherCumulees collisions cumulées, sur les R tours
* @property {number} plancherPairesDistinctes
* @property {number|null} plancherExcedent null quand il n'est pas
* strictement positif
*
* @typedef {Object} Diagnostic
* @property {number} placesManquantes max(0, N − Σ c_t), par tour
* @property {{min: number|null, max: number|null, parPersonne: number[]}} plafondAPriori
* parPersonne dans l'ordre de instance.ids ; min et max null pour
* une population vide
* @property {PlancherCollisions[]} collisions groupes dont le plancher de
* collisions cumulées est > 0, dans l'ordre de instance.groupes
* @property {number|null} redondanceMinimale null sans affilié
* @property {boolean} animateursMemeAppartenance
* @property {number} redondanceImposeeParAnimateurs min(R, T) − 1 ou 0
* @property {Array<{table: number, ancres: number, capacite: number, gelee: boolean}>} ancrage
* chaque table, par identifiant, dans l'ordre de la configuration ;
* gelee quand ses ancrés occupent tous ses sièges
* @property {number} retoursImposes
* @property {{plancher: number, mobilesAuMoins: number}|null} ecartItineraire
*/
import {
LIBRE,
SANS_GROUPE,
STATUT,
nombrePlaces,
nombrePlacesManquantes,
normaliser,
} from './configuration.js';
import { mesurer } from './indicateurs.js';
import { plafondsAPriori } from './plafond.js';
// Paires que forment n personnes : C(n, 2).
const paires = (n) => (n * (n - 1)) / 2;
// Plus petit nombre de paires d'un groupe qu'un tour réunit. poses[t] compte
// les membres que les réservations assoient à la table t ce tour-là ; libres,
// les autres. Chaque libre va, l'un après l'autre, à la table qui compte le
// moins de membres, la première par index à égalité, et y forme une paire
// avec chacun. Le coût Σ C(x_t, 2) est séparable et convexe : ce remplissage
// glouton en donne le minimum sous la seule contrainte x_t ≥ poses[t]. Les
// capacités n'y entrent pas : relâchées, elles laissent un minimum qui minore
// celui de tout plan. Sans table, personne ne s'assied et aucune paire ne se
// forme.
function pairesMinimales(poses, libres) {
const T = poses.length;
if (T === 0) return 0;
const membres = Int32Array.from(poses);
for (let i = 0; i < libres; i += 1) {
let moins = 0;
for (let t = 1; t < T; t += 1) if (membres[t] < membres[moins]) moins = t;
membres[moins] += 1;
}
let total = 0;
for (let t = 0; t < T; t += 1) total += paires(membres[t]);
return total;
}
// Plancher de collisions de chaque groupe (§ 5.6), dans l'ordre de
// instance.groupes ; un groupe n'y figure que si son plancher de collisions
// cumulées est positif. À chaque tour, les membres que les réservations fixent
// à ce tour, ancrés comme partiellement fixés, sont posés à leur table, et
// pairesMinimales place les autres. Les paires d'un même tour sont
// distinctes : le plus grand minimum d'un tour minore les paires distinctes,
// la somme des minimums les collisions cumulées. Un groupe de s membres
// n'offre que C(s, 2) paires distinctes : l'excédent, cumulées − distinctes,
// ne descend pas sous plancherCumulees − C(s, 2), écrit quand c'est positif.
function planchersCollisions({ N, T, R, groupe, groupes, fixe }) {
const membres = groupes.map(() => []);
for (let p = 0; p < N; p += 1) if (groupe[p] !== SANS_GROUPE) membres[groupe[p]].push(p);
const planchers = [];
for (let g = 0; g < groupes.length; g += 1) {
let cumulees = 0;
let distinctes = 0;
for (let r = 0; r < R; r += 1) {
const poses = new Int32Array(T);
let libres = 0;
for (const p of membres[g]) {
const t = fixe[p * R + r];
if (t === LIBRE) libres += 1;
else poses[t] += 1;
}
const minimum = pairesMinimales(poses, libres);
cumulees += minimum;
distinctes = Math.max(distinctes, minimum);
}
if (cumulees === 0) continue;
const excedent = cumulees - paires(membres[g].length);
planchers.push({
groupe: groupes[g],
effectif: membres[g].length,
plancherCumulees: cumulees,
plancherPairesDistinctes: distinctes,
plancherExcedent: excedent > 0 ? excedent : null,
});
}
return planchers;
}
// Redondance minimale (§ 5.6), r_min(p) = max(0, |F(p)| − G) : A(p) ne
// dépasse pas G, et r(p) = |F(p)| − A(p). Elle s'évalue au plus grand |F(p)|
// possible, le plus petit du plafond a priori de p et du nombre d'affiliés
// autres que lui : elle minore la redondance de qui rencontre autant
// d'affiliés, non celle d'un plan qui lui en fait rencontrer moins. Rend le
// plus grand r_min(p) sur tous les participants ; null quand aucun n'est
// affilié.
function redondanceMinimale({ N, groupe, groupes }, aPriori) {
const G = groupes.length;
if (G === 0) return null;
let affilies = 0;
for (let p = 0; p < N; p += 1) if (groupe[p] !== SANS_GROUPE) affilies += 1;
let plusGrande = 0;
for (let p = 0; p < N; p += 1) {
const autres = groupe[p] === SANS_GROUPE ? affilies : affilies - 1;
plusGrande = Math.max(plusGrande, Math.min(aPriori[p], autres) - G);
}
return plusGrande;
}
// Vrai quand les ancrés, les animateurs, sont au moins deux et portent tous
// la même appartenance déclarée (§ 5.6).
function animateursMemeAppartenance({ N, statut, groupe }) {
let commun = SANS_GROUPE;
let animateurs = 0;
for (let p = 0; p < N; p += 1) {
if (statut[p] !== STATUT.ANCRE) continue;
if (groupe[p] === SANS_GROUPE || (animateurs > 0 && groupe[p] !== commun)) return false;
commun = groupe[p];
animateurs += 1;
}
return animateurs >= 2;
}
// Ancrés et capacité de chaque table, dans l'ordre de l'instance, qui est
// celui de la configuration ; gelee quand les ancrés occupent tous les sièges
// (§ 5.9).
function ancrage({ T, idsTables, capacite, ancresParTable }) {
return Array.from({ length: T }, (_, t) => ({
table: idsTables[t],
ancres: ancresParTable[t],
capacite: capacite[t],
gelee: ancresParTable[t] === capacite[t],
}));
}
// Plafond du meilleur itinéraire d'un mobile p qui passe par la table t : le
// plafond a priori de p, la table t réservée au tour 1. La formule ne lit pas
// l'ordre des tours. Pour R ≥ 2, la réservation d'un seul tour rend p
// partiellement fixé (§ 4.2), ce qui ne change ni n ni les ancrés (§ 5.9) :
// seul l'itinéraire est contraint. Pour R = 1, elle couvre chacun des R tours
// et ancre p à la table t : a_t vu de p l'exclut, et n_p, le n de la sonde,
// vaut n − 1 comme pour un mobile ; le plafond est celui du même itinéraire.
// Sans partiellement fixé, seuls les ancrés occupent t au tour 1 ; une table
// où ils laissent un siège reçoit la réservation sans que normaliser lève
// SURRESERVATION.
function plafondPassantPar(configuration, instance, p, t) {
const sonde = normaliser({
...configuration,
reservations: [
...configuration.reservations,
{ participant: instance.ids[p], table: instance.idsTables[t], portee: 'tour', tour: 1 },
],
});
return plafondsAPriori(sonde)[p];
}
// Plancher de l'écart d'itinéraire maximal (§ 12.10.4), écrit pour une
// configuration tendue, Σ c_t = N, sans partiellement fixé ; null ailleurs.
// Là, tout plan qui assied chacun remplit chaque siège à chaque tour :
// l'occupation vaut la capacité, et le plafond réalisé d'un mobile vaut le
// plafond de son itinéraire. Les mobiles partagent alors un plafond a priori
// P*. Une table est inférieure quand le meilleur itinéraire qui passe par elle
// vaut P*_t < P*. Ses c_t − k_t sièges mobiles reçoivent un mobile à chacun
// des R tours : M_inf sièges-tours sur les tables inférieures, dont un mobile
// n'occupe que R au plus. Au moins ⌈M_inf / R⌉ mobiles passent donc par une
// table inférieure et finissent au moins P* − P*_t sous leur plafond a
// priori. Sans table inférieure, rien n'est prouvé : null.
//
// Deux tables de même capacité et de mêmes ancrés s'échangent sans changer
// aucun plafond : P*_t se calcule une fois par couple (c_t, k_t), que la Map
// retrouve. Le couple ne se réduit pas aux sièges libres c_t − k_t : à sièges
// libres égaux, les sièges qui tournent sont les mêmes, et les k_t ancrés
// s'ajoutent au plafond hors du terme que n_p borne.
function ecartItineraire(configuration, instance, aPriori) {
const { N, T, R, capacite, ancresParTable, statut } = instance;
if (nombrePlaces(instance) !== N || statut.includes(STATUT.PARTIELLEMENT_FIXE)) return null;
const mobile = statut.indexOf(STATUT.MOBILE);
if (mobile === -1) return null;
const plafondCommun = aPriori[mobile];
const parGabarit = new Map();
let siegesToursInferieurs = 0;
let plancher = null;
for (let t = 0; t < T; t += 1) {
const sieges = capacite[t] - ancresParTable[t];
// Une table gelée n'a aucun siège mobile, aucun itinéraire n'y passe, et
// la sonde la surréserverait.
if (sieges === 0) continue;
const gabarit = `${capacite[t]}|${ancresParTable[t]}`;
let passant = parGabarit.get(gabarit);
if (passant === undefined) {
passant = plafondPassantPar(configuration, instance, mobile, t);
parGabarit.set(gabarit, passant);
}
if (passant < plafondCommun) {
siegesToursInferieurs += sieges * R;
const ecartPassant = plafondCommun - passant;
if (plancher === null || ecartPassant < plancher) plancher = ecartPassant;
}
}
if (siegesToursInferieurs === 0) return null;
return { plancher, mobilesAuMoins: Math.ceil(siegesToursInferieurs / R) };
}
// Plus petite et plus grande valeur, null pour une liste vide.
function etendue(valeurs) {
if (valeurs.length === 0) return { min: null, max: null };
let min = valeurs[0];
let max = valeurs[0];
for (const valeur of valeurs) {
if (valeur < min) min = valeur;
if (valeur > max) max = valeur;
}
return { min, max };
}
/**
* Diagnostic d'une configuration, avant la recherche (§ 5.6).
*
* - placesManquantes : nombrePlacesManquantes ; la recherche refuse une
* configuration où elles sont positives, le diagnostic les chiffre (§ 5.9).
* - plafondAPriori : plafondsAPriori, et son étendue.
* - collisions : planchers de collisions par groupe, posés sur les tours
* fixés, capacités relâchées.
* - redondanceMinimale : max_p max(0, min(a priori(p), affiliés autres que
* p) − G).
* - animateursMemeAppartenance, et redondanceImposeeParAnimateurs : la forme
* close min(R, T) − 1 du § 5.6, qui compte un animateur à chaque table
* visitée ; 0 quand les animateurs ne partagent pas une appartenance.
* - ancrage : ancrés, capacité et gel de chaque table.
* - retoursImposes : la rangée de instance.fixe se lit comme le plan qui
* assied chacun aux seuls tours fixés, en réserve ailleurs ; sa mesure
* compte max(0, f − 1) retours pour f tours réservés à une même table, ce
* que mesure tout plan qui honore les réservations (§ 5.4).
* - ecartItineraire : plancher de l'écart d'itinéraire maximal, ou null.
*
* @param {import('./types.js').Configuration} configuration
* @returns {Diagnostic}
*/
export function diagnostiquer(configuration) {
const instance = normaliser(configuration);
const aPriori = plafondsAPriori(instance);
const memeAppartenance = animateursMemeAppartenance(instance);
return {
placesManquantes: nombrePlacesManquantes(instance),
plafondAPriori: { ...etendue(aPriori), parPersonne: aPriori },
collisions: planchersCollisions(instance),
redondanceMinimale: redondanceMinimale(instance, aPriori),
animateursMemeAppartenance: memeAppartenance,
redondanceImposeeParAnimateurs: memeAppartenance ? Math.min(instance.R, instance.T) - 1 : 0,
ancrage: ancrage(instance),
retoursImposes: mesurer(instance, instance.fixe).totalRetoursImposes,
ecartItineraire: ecartItineraire(configuration, instance, aPriori),
};
}

File diff suppressed because it is too large Load diff

28
src/moteur/erreurs.js Normal file
View file

@ -0,0 +1,28 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Erreurs que lève le moteur. Le code d'une ErreurConfiguration nomme la
// règle enfreinte ; ses détails désignent participants et tables par
// identifiant, les tours par leur numéro à partir de 1, et une réservation,
// qui n'a pas d'identifiant, par son rang à partir de 0 dans la liste qui la
// porte. L'appelant lit le code et les détails ; le message, code et détails
// en JSON, sert au journal et n'est pas un texte affiché, que fournit la
// table des libellés (§ 14.6).
export class ErreurConfiguration extends Error {
/**
* @param {string} code
* @param {Object} [details]
*/
constructor(code, details = {}) {
super(`${code} ${JSON.stringify(details)}`);
this.code = code;
this.details = details;
}
}
ErreurConfiguration.prototype.name = 'ErreurConfiguration';
// Levée par une recherche interrompue à la demande : aucun résultat partiel
// ne l'accompagne.
export class ErreurAnnulee extends Error {}
ErreurAnnulee.prototype.name = 'ErreurAnnulee';

296
src/moteur/indicateurs.js Normal file
View file

@ -0,0 +1,296 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Indicateurs d'un plan (§ 5.4), par recalcul complet : la mesure qui fait
// foi, dont sortent les chiffres affichés et ceux du classement (§ 5.10). Le
// plan se mesure entier, les N participants et les R tours ; les
// réservations ne retirent rien de la mesure, elles séparent seulement, dans
// les retours et les rencontres répétées, ce qu'elles imposent de ce que le
// moteur choisit. Tout tableau par personne suit l'ordre de instance.ids.
//
// Une rencontre est une paire de personnes assises à la même table au même
// tour : la réserve n'est pas une table, une table vide ou à un seul occupant
// ne forme aucune paire, et personne ne se rencontre soi-même. Deux personnes
// sans appartenance ne sont pas collègues. Un ensemble vide rend null, jamais
// 0 : le taux de diversité de qui ne rencontre aucun affilié, le minimum et la
// moyenne d'une population sans membre.
import {
RESERVE,
SANS_GROUPE,
exigerPlanIndexe,
replierParPopulation,
} from './configuration.js';
/**
* @typedef {Object} Agregat
* @property {number|null} min null pour une population vide
* @property {number|null} moyenne null pour une population vide
* @property {number} effectif
*
* @typedef {Object} TroisAgregats
* @property {Agregat} tous
* @property {Agregat} mobiles mobiles et partiellement fixés (§ 5.4)
* @property {Agregat} ancres
*
* @typedef {Object} Mesures
* @property {number[]} rencontres |met(p)|, ordre canonique
* @property {number[]} affilies |F(p)|
* @property {number[]} appartenancesVues A(p) — appartenances des affiliés
* rencontrés, la sienne comprise quand
* un collègue est rencontré
* @property {number[]} redondance r(p) = |F(p)| − A(p)
* @property {(number|null)[]} diversite d(p) = A(p) / |F(p)|, null si |F(p)| = 0
* @property {number[]} toursAssis
* @property {number[]} retoursChoisis par personne
* @property {number[]} retoursImposes par personne
* @property {TroisAgregats} aggRencontres
* @property {TroisAgregats} aggRedondance
* @property {number} totalRedondance Σ_p r(p), le seul total de la
* redondance : la recherche et le
* classement le lisent
* @property {number} collisionsCumulees Σ paires de même groupe × tours ensemble
* @property {number} pairesDistinctes paires de même groupe réunies ≥ 1 fois
* @property {number} excedentCollisions cumulées − distinctes
* @property {{choisies: number, imposees: number}} rencontresRepetees
* paires réunies ≥ 2 fois ; « imposée » quand chaque rencontre de la
* paire a lieu à une table où les deux sont réservés ce tour-là
* @property {number} maxRencontresPaire sur toutes les paires ; 0 si aucune
* @property {number} totalRetoursChoisis
* @property {number} totalRetoursImposes
* @property {Array<{groupe: string, effectif: number, collisionsCumulees: number,
* pairesDistinctes: number, excedent: number}>} parGroupe
* ordre de instance.groupes
*/
// Plus grand compte que tient un Uint16Array.
const UINT16_MAX = 0xffff;
// Rang de la paire (a, b), a < b, dans le triangle supérieur d'une matrice
// N × N rangé ligne par ligne : la ligne a s'ouvre après les
// (N − 1) + (N − 2) + … + (N − a) paires des lignes qui la précèdent.
const rangPaire = (N, a, b) => (a * (2 * N - a - 1)) / 2 + (b - a - 1);
// Rencontres de chaque paire, rangées par rangPaire, en parcourant chaque
// table de chaque tour. choisie vaut 1 pour une paire dont une rencontre au
// moins a lieu hors d'une table où les deux sont réservés ce tour-là. Une
// paire se réunit au plus une fois par tour : R borne chaque compte, qu'un
// Uint16Array tient tant que R ≤ 65 535.
function compterRencontres({ N, T, R, fixe }, tableDe) {
const paires = (N * (N - 1)) / 2;
const compte = R <= UINT16_MAX ? new Uint16Array(paires) : new Uint32Array(paires);
const choisie = new Uint8Array(paires);
for (let r = 0; r < R; r += 1) {
// Occupants de chaque table, par index croissants : a < b pour toute
// paire (liste[i], liste[j]) avec i < j. La boucle intérieure part du
// voisin suivant : une liste vide ou d'un seul occupant ne forme aucune
// paire, et nul n'est apparié à lui-même.
const assis = Array.from({ length: T }, () => []);
for (let p = 0; p < N; p += 1) {
const t = tableDe[p * R + r];
if (t !== RESERVE) assis[t].push(p);
}
for (let t = 0; t < T; t += 1) {
const liste = assis[t];
for (let i = 0; i < liste.length; i += 1) {
const a = liste[i];
const aReserve = fixe[a * R + r] === t;
for (let j = i + 1; j < liste.length; j += 1) {
const b = liste[j];
const k = rangPaire(N, a, b);
compte[k] += 1;
if (!aReserve || fixe[b * R + r] !== t) choisie[k] = 1;
}
}
}
}
return { compte, choisie };
}
// Lecture du triangle des paires, dans l'ordre de rangPaire. Par personne :
// |met(p)|, |F(p)| et A(p), vu[p * G + g] notant que p a déjà croisé un
// affilié du groupe g. Par groupe : une paire de collègues réunie n fois
// porte n collisions cumulées et une paire distincte. Sur toutes les paires :
// les rencontres répétées, choisies ou imposées, et le plus grand compte.
function lirePaires({ N, groupe, groupes }, { compte, choisie }) {
const G = groupes.length;
const rencontres = new Int32Array(N);
const affilies = new Int32Array(N);
const vues = new Int32Array(N);
const vu = new Uint8Array(N * G);
const collisions = new Array(G).fill(0);
const distinctes = new Array(G).fill(0);
let choisies = 0;
let imposees = 0;
let maxRencontresPaire = 0;
// p rencontre q : q compte dans met(p) ; un affilié compte aussi dans F(p),
// et son groupe compte dans A(p) au premier membre de ce groupe que p
// rencontre.
const rencontrer = (p, q) => {
rencontres[p] += 1;
const g = groupe[q];
if (g === SANS_GROUPE) return;
affilies[p] += 1;
if (vu[p * G + g] === 0) {
vu[p * G + g] = 1;
vues[p] += 1;
}
};
let k = 0;
for (let a = 0; a < N; a += 1) {
for (let b = a + 1; b < N; b += 1, k += 1) {
const n = compte[k];
if (n === 0) continue;
rencontrer(a, b);
rencontrer(b, a);
if (n > maxRencontresPaire) maxRencontresPaire = n;
if (n >= 2) {
if (choisie[k] === 1) choisies += 1;
else imposees += 1;
}
const g = groupe[a];
if (g !== SANS_GROUPE && g === groupe[b]) {
collisions[g] += n;
distinctes[g] += 1;
}
}
}
return {
rencontres,
affilies,
vues,
collisions,
distinctes,
rencontresRepetees: { choisies, imposees },
maxRencontresPaire,
};
}
// Tours assis et retours de chaque personne (§ 5.4). Pour une table visitée
// m fois, dont f à un tour où la personne y est réservée : max(0, f − 1)
// retours imposés et (m − 1) − imposés retours choisis. visites et reservees
// comptent par table les tours de la personne en cours ; le second parcours
// de ses tours solde chaque table à sa première occurrence et remet ses
// compteurs à zéro, si bien que la personne suivante les trouve nuls.
function compterRetours({ N, T, R, fixe }, tableDe) {
const toursAssis = [];
const retoursChoisis = [];
const retoursImposes = [];
const visites = new Int32Array(T);
const reservees = new Int32Array(T);
for (let p = 0; p < N; p += 1) {
let assis = 0;
for (let r = 0; r < R; r += 1) {
const t = tableDe[p * R + r];
if (t === RESERVE) continue;
assis += 1;
visites[t] += 1;
if (fixe[p * R + r] === t) reservees[t] += 1;
}
let choisis = 0;
let imposes = 0;
for (let r = 0; r < R; r += 1) {
const t = tableDe[p * R + r];
if (t === RESERVE || visites[t] === 0) continue;
const imposesTable = Math.max(0, reservees[t] - 1);
imposes += imposesTable;
choisis += visites[t] - 1 - imposesTable;
visites[t] = 0;
reservees[t] = 0;
}
toursAssis.push(assis);
retoursChoisis.push(choisis);
retoursImposes.push(imposes);
}
return { toursAssis, retoursChoisis, retoursImposes };
}
// Population sans membre. min reste null tant qu'aucune valeur n'y entre :
// aucun Infinity ne sert de minimum initial.
const VIDE = Object.freeze({ min: null, somme: 0, effectif: 0 });
// La population, une valeur de plus ; celle qu'elle reçoit reste intacte.
const ajouter = ({ min, somme, effectif }, valeur) => ({
min: min === null || valeur < min ? valeur : min,
somme: somme + valeur,
effectif: effectif + 1,
});
// Agrégat d'une population ; la moyenne d'une population vide vaut null.
const conclure = ({ min, somme, effectif }) => ({
min,
moyenne: effectif === 0 ? null : somme / effectif,
effectif,
});
// Minimum, moyenne et effectif de valeurs[p] sur les trois populations du
// § 5.4, que replierParPopulation forme.
function troisAgregats(valeurs, statut) {
const { tous, mobiles, ancres } = replierParPopulation(valeurs, statut, VIDE, ajouter);
return { tous: conclure(tous), mobiles: conclure(mobiles), ancres: conclure(ancres) };
}
const somme = (valeurs) => valeurs.reduce((total, valeur) => total + valeur, 0);
/**
* Mesure complète d'un plan indexé : la mesure qui fait foi (§ 5.10). Ne
* modifie ni l'instance ni le plan. Lève ce que lève exigerPlanIndexe
* (configuration.js) avant de lire une case : une case manquante ou d'une
* autre forme lèverait plus loin une erreur qui ne nommerait ni la personne
* ni le tour.
*
* @param {import('./types.js').Instance} instance
* @param {ArrayLike<number>} tableDe tableDe[p * R + r] = index de table, RESERVE pour la réserve
* @returns {Mesures}
*/
export function mesurer(instance, tableDe) {
exigerPlanIndexe(instance, tableDe);
const { N, groupe, groupes, statut } = instance;
const paires = lirePaires(instance, compterRencontres(instance, tableDe));
const { collisions, distinctes } = paires;
const rencontres = Array.from(paires.rencontres);
const affilies = Array.from(paires.affilies);
const appartenancesVues = Array.from(paires.vues);
const redondance = affilies.map((f, p) => f - appartenancesVues[p]);
const diversite = affilies.map((f, p) => (f === 0 ? null : appartenancesVues[p] / f));
const effectifs = new Array(groupes.length).fill(0);
for (let p = 0; p < N; p += 1) {
if (groupe[p] !== SANS_GROUPE) effectifs[groupe[p]] += 1;
}
const parGroupe = groupes.map((libelle, g) => ({
groupe: libelle,
effectif: effectifs[g],
collisionsCumulees: collisions[g],
pairesDistinctes: distinctes[g],
excedent: collisions[g] - distinctes[g],
}));
const collisionsCumulees = somme(collisions);
const pairesDistinctes = somme(distinctes);
const { toursAssis, retoursChoisis, retoursImposes } = compterRetours(instance, tableDe);
return {
rencontres,
affilies,
appartenancesVues,
redondance,
diversite,
toursAssis,
retoursChoisis,
retoursImposes,
aggRencontres: troisAgregats(rencontres, statut),
aggRedondance: troisAgregats(redondance, statut),
totalRedondance: somme(redondance),
collisionsCumulees,
pairesDistinctes,
excedentCollisions: collisionsCumulees - pairesDistinctes,
rencontresRepetees: paires.rencontresRepetees,
maxRencontresPaire: paires.maxRencontresPaire,
totalRetoursChoisis: somme(retoursChoisis),
totalRetoursImposes: somme(retoursImposes),
parGroupe,
};
}

View file

@ -0,0 +1,579 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des indicateurs (§ 5.4, § 12.6, § 14.10). Chaque valeur attendue
// est écrite en clair : recopiée de la spécification, ou comptée à la main
// sur le plan qui la précède, jamais calculée par le module éprouvé. Un plan
// s'écrit par identifiants, une liste par table, et passe par normaliser et
// indexerPlan comme un plan enregistré.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { indexerPlan, normaliser } from './configuration.js';
import { mesurer } from './indicateurs.js';
const SANS_CONTRAINTE = {
separerAppartenances: false,
nouveauxVoisins: false,
nouvelleTable: false,
varierAppartenances: false,
};
const tous = (participant, table) => ({ participant, table, portee: 'tous' });
const auTour = (participant, table, tour) => ({ participant, table, portee: 'tour', tour });
// Agrégat d'une population vide.
const AUCUN = { min: null, moyenne: null, effectif: 0 };
// Appartenances des participants 1..N : le libellé du groupe qui contient
// l'identifiant, null hors de tout groupe. groupes est une liste de couples
// [libellé, identifiants].
function appartenances(N, groupes) {
return Array.from({ length: N }, (_, i) => {
const groupe = groupes.find(([, membres]) => membres.includes(i + 1));
return groupe === undefined ? null : groupe[0];
});
}
// Instance et plan indexé d'un plan écrit par identifiants. Le participant
// i + 1 porte appartenance[i] ; les tables portent les identifiants 1..T,
// toutes de la capacité donnée ; tours[r][i] liste les participants assis à
// la table i + 1 au tour r + 1, et reserves[r] ceux qui n'y sont assis nulle
// part.
function preparer({
appartenance,
capacite,
tours,
reserves = tours.map(() => []),
reservations = [],
}) {
const participants = appartenance.map((libelle, i) => ({
id: i + 1,
nom: `P${i + 1}`,
appartenance: libelle,
}));
const tables = tours[0].map((_, i) => ({ id: i + 1, numero: i + 1, capacite }));
const instance = normaliser({
participants,
tables,
tours: tours.length,
reservations,
contraintes: SANS_CONTRAINTE,
});
const plan = { tables: tables.map(({ id }) => id), tours, reserves };
return { instance, tableDe: indexerPlan(instance, plan) };
}
function mesurerPlan(description) {
const { instance, tableDe } = preparer(description);
return mesurer(instance, tableDe);
}
// Plan parfait de la petite démonstration (§ 15.3) : quatre tables de trois
// sièges, quatre tours, chaque table réunit à chaque tour un membre de A, de
// B et de C.
const PLAN_PARFAIT = [
[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]],
[[6, 9, 12], [3, 8, 11], [2, 5, 10], [1, 4, 7]],
[[4, 8, 10], [2, 7, 12], [1, 6, 11], [3, 5, 9]],
[[5, 7, 11], [1, 9, 10], [3, 4, 12], [2, 6, 8]],
];
const PETITE = appartenances(12, [
['A', [1, 5, 8, 12]],
['B', [2, 4, 9, 11]],
['C', [3, 6, 7, 10]],
]);
// Variante « conflit inévitable » : 10 passe de C à A.
const VARIANTE = appartenances(12, [
['A', [1, 5, 8, 10, 12]],
['B', [2, 4, 9, 11]],
['C', [3, 6, 7]],
]);
// Trois tables de trois sièges, quatre tours. 1 est ancré à la table 1 ; 2
// est mobile ; 3, réservé à la table 2 au tour 3, et 4, réservé à la table 3
// aux tours 1 et 3, sont partiellement fixés. Chaque réservation est tenue.
const PLAN_RESERVATIONS = {
appartenance: [null, null, null, null],
capacite: 3,
tours: [
[[1], [2, 3], [4]],
[[1, 3], [], [2, 4]],
[[1, 2], [3], [4]],
[[1, 4], [2], [3]],
],
reservations: [tous(1, 1), auTour(3, 2, 3), auTour(4, 3, 1), auTour(4, 3, 3)],
};
// Deux ancrés à la table 1, seule table, quatre tours.
const DEUX_ANCRES = {
appartenance: [null, null],
capacite: 2,
tours: [[[1, 2]], [[1, 2]], [[1, 2]], [[1, 2]]],
reservations: [tous(1, 1), tous(2, 1)],
};
describe('mesurer', () => {
test('plan parfait de la petite démonstration : huit rencontres chacun, ni collision ni répétition', () => {
// Aucun retour : chacun visite les quatre tables. Sans réservation, la
// population des ancrés est vide.
assert.deepEqual(mesurerPlan({ appartenance: PETITE, capacite: 3, tours: PLAN_PARFAIT }), {
rencontres: new Array(12).fill(8),
affilies: new Array(12).fill(8),
appartenancesVues: new Array(12).fill(2),
redondance: new Array(12).fill(6),
diversite: new Array(12).fill(0.25),
toursAssis: new Array(12).fill(4),
retoursChoisis: new Array(12).fill(0),
retoursImposes: new Array(12).fill(0),
aggRencontres: {
tous: { min: 8, moyenne: 8, effectif: 12 },
mobiles: { min: 8, moyenne: 8, effectif: 12 },
ancres: AUCUN,
},
aggRedondance: {
tous: { min: 6, moyenne: 6, effectif: 12 },
mobiles: { min: 6, moyenne: 6, effectif: 12 },
ancres: AUCUN,
},
totalRedondance: 72,
collisionsCumulees: 0,
pairesDistinctes: 0,
excedentCollisions: 0,
rencontresRepetees: { choisies: 0, imposees: 0 },
maxRencontresPaire: 1,
totalRetoursChoisis: 0,
totalRetoursImposes: 0,
parGroupe: [
{ groupe: 'A', effectif: 4, collisionsCumulees: 0, pairesDistinctes: 0, excedent: 0 },
{ groupe: 'B', effectif: 4, collisionsCumulees: 0, pairesDistinctes: 0, excedent: 0 },
{ groupe: 'C', effectif: 4, collisionsCumulees: 0, pairesDistinctes: 0, excedent: 0 },
],
});
});
test('variante 5, 4, 3 : quatre collisions cumulées sur quatre paires distinctes, excédent nul', () => {
const mesures = mesurerPlan({ appartenance: VARIANTE, capacite: 3, tours: PLAN_PARFAIT });
const { collisionsCumulees, pairesDistinctes, excedentCollisions } = mesures;
assert.deepEqual(
{ collisionsCumulees, pairesDistinctes, excedentCollisions },
{ collisionsCumulees: 4, pairesDistinctes: 4, excedentCollisions: 0 },
);
assert.deepEqual(mesures.parGroupe, [
{ groupe: 'A', effectif: 5, collisionsCumulees: 4, pairesDistinctes: 4, excedent: 0 },
{ groupe: 'B', effectif: 4, collisionsCumulees: 0, pairesDistinctes: 0, excedent: 0 },
{ groupe: 'C', effectif: 3, collisionsCumulees: 0, pairesDistinctes: 0, excedent: 0 },
]);
// 1, 5, 8 et 12 croisent chacun 10, leur collègue : A(p) compte leur
// propre appartenance avec les deux autres. 10 croise A et B, jamais C.
assert.deepEqual(mesures.appartenancesVues, [3, 2, 2, 2, 3, 2, 2, 3, 2, 2, 2, 3]);
});
test("l'excédent compte les retrouvailles de collègues au-delà de la première", () => {
// Deux tables de quatre, trois tours ; X = {1, 2, 3, 4}, les autres sans
// appartenance.
const appartenance = appartenances(8, [['X', [1, 2, 3, 4]]]);
const collisions = ({ collisionsCumulees, pairesDistinctes, excedentCollisions, parGroupe }) => ({
collisionsCumulees,
pairesDistinctes,
excedentCollisions,
parGroupe,
});
const reparti = mesurerPlan({
appartenance,
capacite: 4,
tours: [
[[1, 2, 5, 6], [3, 4, 7, 8]],
[[1, 3, 5, 7], [2, 4, 6, 8]],
[[1, 4, 5, 8], [2, 3, 6, 7]],
],
});
assert.deepEqual(collisions(reparti), {
collisionsCumulees: 6,
pairesDistinctes: 6,
excedentCollisions: 0,
parGroupe: [{ groupe: 'X', effectif: 4, collisionsCumulees: 6, pairesDistinctes: 6, excedent: 0 }],
});
// 5 et 6, puis 7 et 8, se retrouvent aussi à chaque tour : deux personnes
// sans appartenance ne sont pas des collègues.
const concentre = mesurerPlan({
appartenance,
capacite: 4,
tours: [
[[1, 2, 5, 6], [3, 4, 7, 8]],
[[1, 2, 5, 6], [3, 4, 7, 8]],
[[1, 2, 5, 6], [3, 4, 7, 8]],
],
});
assert.deepEqual(collisions(concentre), {
collisionsCumulees: 6,
pairesDistinctes: 2,
excedentCollisions: 4,
parGroupe: [{ groupe: 'X', effectif: 4, collisionsCumulees: 6, pairesDistinctes: 2, excedent: 4 }],
});
});
test("une table vide et une table à un seul occupant n'ajoutent aucune paire ni aucune rencontre de soi", () => {
// Au tour 1, la table 3 est vide, 4 et 5 sont seuls à leur table. Au
// tour 2, 5 l'est encore, et la table 1 est vide.
const mesures = mesurerPlan({
appartenance: ['X', 'X', 'Y', 'X', null],
capacite: 3,
tours: [
[[1, 2, 3], [4], [], [5]],
[[], [1, 4], [2, 3], [5]],
],
});
assert.deepEqual(mesures.rencontres, [3, 2, 2, 1, 0]);
assert.deepEqual(mesures.affilies, [3, 2, 2, 1, 0]);
assert.deepEqual(mesures.appartenancesVues, [2, 2, 1, 1, 0]);
assert.deepEqual(mesures.redondance, [1, 0, 1, 0, 0]);
// 5 ne rencontre personne : son taux de diversité n'a pas de diviseur.
assert.deepEqual(mesures.diversite, [2 / 3, 1, 1 / 2, 1, null]);
// (1, 2) au tour 1 et (1, 4) au tour 2 ; 4, seul de X à sa table au tour
// 1, ne s'y croise pas lui-même.
assert.equal(mesures.collisionsCumulees, 2);
// (2, 3), réunis aux deux tours, est la seule paire répétée.
assert.equal(mesures.maxRencontresPaire, 2);
assert.deepEqual(mesures.rencontresRepetees, { choisies: 1, imposees: 0 });
});
test('totalRedondance somme r(p) sur les personnes, et non son plus grand', () => {
// Le plan de l'épreuve précédente : r(p) vaut 1, 0, 1, 0 et 0. Le total
// est le seul Σ r(p) du moteur ; la recherche et le classement le lisent.
const mesures = mesurerPlan({
appartenance: ['X', 'X', 'Y', 'X', null],
capacite: 3,
tours: [
[[1, 2, 3], [4], [], [5]],
[[], [1, 4], [2, 3], [5]],
],
});
assert.deepEqual(mesures.redondance, [1, 0, 1, 0, 0]);
assert.equal(mesures.totalRedondance, 2);
});
test("F(p) ne retient que les affiliés : qui ne croise aucun affilié n'a pas de taux de diversité", () => {
// 3, seul affilié de la table, rencontre deux personnes sans
// appartenance ; chacune d'elles rencontre un seul affilié, 3.
const mesures = mesurerPlan({ appartenance: [null, null, 'X'], capacite: 3, tours: [[[1, 2, 3]]] });
assert.deepEqual(mesures.rencontres, [2, 2, 2]);
assert.deepEqual(mesures.affilies, [1, 1, 0]);
assert.deepEqual(mesures.appartenancesVues, [1, 1, 0]);
assert.deepEqual(mesures.diversite, [1, 1, null]);
// La redondance se lit sur les affiliés rencontrés, |F(p)| − A(p), et non
// sur toutes les rencontres : personne ne recroise une appartenance.
assert.deepEqual(mesures.redondance, [0, 0, 0]);
assert.deepEqual(mesures.aggRedondance, {
tous: { min: 0, moyenne: 0, effectif: 3 },
mobiles: { min: 0, moyenne: 0, effectif: 3 },
ancres: AUCUN,
});
});
test("la réserve n'est pas une table : seuls les tours assis comptent", () => {
// 6 est en réserve aux tours 1 et 2, 5 au tour 2. Ils partagent
// l'appartenance X et ne sont jamais assis ensemble.
const mesures = mesurerPlan({
appartenance: [null, null, null, null, 'X', 'X'],
capacite: 3,
tours: [
[[1, 2, 5], [3, 4]],
[[1, 3], [2, 4]],
[[1, 4, 6], [2, 3, 5]],
],
reserves: [[6], [5, 6], []],
});
assert.deepEqual(mesures.toursAssis, [3, 3, 3, 3, 2, 1]);
assert.deepEqual(mesures.rencontres, [5, 4, 4, 4, 3, 2]);
assert.equal(mesures.collisionsCumulees, 0);
// Deux passages en réserve ne font pas un retour.
assert.deepEqual(mesures.retoursChoisis, [2, 1, 1, 1, 0, 0]);
// Une personne en réserve à un tour reste dans les agrégats.
assert.deepEqual(mesures.aggRencontres.tous, { min: 2, moyenne: 22 / 6, effectif: 6 });
});
test('les retours imposés par les réservations se comptent à part des retours choisis', () => {
// 1 visite quatre fois la table 1, réservée à chaque tour : trois retours
// imposés. 2 visite deux fois la table 2, sans réservation : un retour
// choisi. 3 est placé à la table 2 au tour 1, puis y revient au tour 3 où
// il y est réservé : un retour choisi. 4 visite trois fois la table 3,
// dont deux réservées : un retour imposé et un choisi.
const mesures = mesurerPlan(PLAN_RESERVATIONS);
assert.deepEqual(mesures.retoursImposes, [3, 0, 0, 1]);
assert.deepEqual(mesures.retoursChoisis, [0, 1, 1, 1]);
assert.equal(mesures.totalRetoursImposes, 4);
assert.equal(mesures.totalRetoursChoisis, 3);
});
test('les agrégats portent sur tous et rangent les partiellement fixés parmi les mobiles', () => {
// 1 est ancré, 2 mobile, 3 et 4 partiellement fixés.
const mesures = mesurerPlan(PLAN_RESERVATIONS);
assert.deepEqual(mesures.rencontres, [3, 3, 2, 2]);
assert.deepEqual(mesures.aggRencontres, {
tous: { min: 2, moyenne: 10 / 4, effectif: 4 },
mobiles: { min: 2, moyenne: 7 / 3, effectif: 3 },
ancres: { min: 3, moyenne: 3, effectif: 1 },
});
});
test('une population vide rend null, jamais 0 ni Infinity', () => {
const sansAncre = mesurerPlan({ appartenance: PETITE, capacite: 3, tours: PLAN_PARFAIT });
assert.deepEqual(sansAncre.aggRencontres.ancres, AUCUN);
assert.deepEqual(sansAncre.aggRedondance.ancres, AUCUN);
const sansMobile = mesurerPlan(DEUX_ANCRES);
assert.deepEqual(sansMobile.aggRencontres.mobiles, AUCUN);
assert.deepEqual(sansMobile.aggRedondance.mobiles, AUCUN);
});
test('deux ancrés à la même table : une rencontre répétée, imposée et non choisie', () => {
const mesures = mesurerPlan(DEUX_ANCRES);
assert.deepEqual(mesures.rencontresRepetees, { choisies: 0, imposees: 1 });
assert.equal(mesures.maxRencontresPaire, 4);
assert.deepEqual(mesures.retoursImposes, [3, 3]);
assert.deepEqual(mesures.retoursChoisis, [0, 0]);
});
test('une seule rencontre hors réservation rend la répétition choisie', () => {
// 1 et 2 sont ancrés à la table 1 ; 3 y est réservé au seul tour 1 et y
// reste au tour 2 ; 4 est mobile. (1, 2) se réunit quatre fois, chaque
// fois réservés tous deux ; (1, 3) et (2, 3) au tour 1 réservés, au tour
// 2 non ; (3, 4) aux tours 3 et 4, sans réservation.
const mesures = mesurerPlan({
appartenance: [null, null, null, null],
capacite: 3,
tours: [
[[1, 2, 3], [4]],
[[1, 2, 3], [4]],
[[1, 2], [3, 4]],
[[1, 2], [3, 4]],
],
reservations: [tous(1, 1), tous(2, 1), auTour(3, 1, 1)],
});
assert.deepEqual(mesures.rencontresRepetees, { choisies: 3, imposees: 1 });
});
describe('une rencontre hors réservation rend la répétition choisie, quels que soient son rang et le membre non réservé', () => {
// 1 et 2 sont assis ensemble à la table 1 aux trois tours. L'un y est
// ancré ; l'autre n'y est réservé qu'aux deux tours autres que « libre ».
// La rencontre de ce tour-là n'est réservée que pour l'ancré : la
// répétition est choisie, que cette rencontre soit la première, celle du
// milieu ou la dernière, et que le membre non réservé soit le premier de
// la paire ou le second. Le partiellement fixé visite trois fois la
// table, dont deux réservées : un retour imposé, un choisi. L'ancré,
// réservé aux trois visites, compte deux retours imposés. Chaque cas est
// une épreuve : un écart nomme chacun des cas qu'il touche.
const cas = [
{ libre: 1, partiel: 1, ancre: 2, retoursImposes: [1, 2], retoursChoisis: [1, 0] },
{ libre: 2, partiel: 1, ancre: 2, retoursImposes: [1, 2], retoursChoisis: [1, 0] },
{ libre: 3, partiel: 1, ancre: 2, retoursImposes: [1, 2], retoursChoisis: [1, 0] },
{ libre: 1, partiel: 2, ancre: 1, retoursImposes: [2, 1], retoursChoisis: [0, 1] },
{ libre: 2, partiel: 2, ancre: 1, retoursImposes: [2, 1], retoursChoisis: [0, 1] },
{ libre: 3, partiel: 2, ancre: 1, retoursImposes: [2, 1], retoursChoisis: [0, 1] },
];
for (const { libre, partiel, ancre, retoursImposes, retoursChoisis } of cas) {
test(`rencontre du tour ${libre} réservée pour ${ancre} seul`, () => {
const mesures = mesurerPlan({
appartenance: [null, null],
capacite: 2,
tours: [[[1, 2]], [[1, 2]], [[1, 2]]],
reservations: [
tous(ancre, 1),
...[1, 2, 3].filter((tour) => tour !== libre).map((tour) => auTour(partiel, 1, tour)),
],
});
assert.deepEqual(mesures.rencontresRepetees, { choisies: 1, imposees: 0 });
assert.deepEqual(mesures.retoursImposes, retoursImposes);
assert.deepEqual(mesures.retoursChoisis, retoursChoisis);
});
}
});
test('une répétition imposée se lit sur les réservations du tour des deux membres, ancrés ou non', () => {
// 2 est ancré à la table 1 ; 3 et 4, réservés à la table 2 aux tours 2 et
// 3, sont partiellement fixés ; 1 est mobile. (1, 2) se réunit aux trois
// tours : 1, le premier membre de la paire, n'est jamais réservé, la
// répétition est choisie. (3, 4) se réunit aux tours 2 et 3, chaque fois
// à la table où tous deux sont réservés ce tour-là : la répétition est
// imposée, bien qu'aucun des deux ne soit ancré et que 3 ne soit réservé
// nulle part au tour 1.
const mesures = mesurerPlan({
appartenance: [null, null, null, null],
capacite: 3,
tours: [
[[1, 2, 4], [3]],
[[1, 2], [3, 4]],
[[1, 2], [3, 4]],
],
reservations: [tous(2, 1), auTour(3, 2, 2), auTour(3, 2, 3), auTour(4, 2, 2), auTour(4, 2, 3)],
});
assert.deepEqual(mesures.rencontresRepetees, { choisies: 1, imposees: 1 });
});
test("une visite n'est réservée qu'à la table de la réservation : un plan qui ne la tient pas n'impose rien", () => {
// 1 et 2 sont ancrés à la table 1 ; le plan les assoit ensemble à la
// table 2 aux deux tours. Un tel plan, en dérive, se conserve et se
// mesure (§ 9) : la réservation qu'il ne tient pas n'impose ni leurs
// retours ni leur répétition, qui sont choisis.
const mesures = mesurerPlan({
appartenance: [null, null],
capacite: 2,
tours: [
[[], [1, 2]],
[[], [1, 2]],
],
reservations: [tous(1, 1), tous(2, 1)],
});
assert.deepEqual(mesures.rencontresRepetees, { choisies: 1, imposees: 0 });
assert.deepEqual(mesures.retoursImposes, [0, 0]);
assert.deepEqual(mesures.retoursChoisis, [1, 1]);
});
test("un seul membre réservé ailleurs suffit à rendre la répétition choisie : réservé ce tour-là ne suffit pas, il faut l'être à cette table", () => {
// Deux plans en dérive, la paire assise à la table 2 aux deux tours. Dans
// le premier, le premier membre est réservé à la table 1 et le second à la
// table 2 ; dans le second, l'inverse. Chacun des deux membres est donc
// tour à tour celui qui n'est pas réservé à la table de la rencontre.
for (const reservations of [[tous(1, 1), tous(2, 2)], [tous(1, 2), tous(2, 1)]]) {
const mesures = mesurerPlan({
appartenance: [null, null],
capacite: 2,
tours: [
[[], [1, 2]],
[[], [1, 2]],
],
reservations,
});
assert.deepEqual(
mesures.rencontresRepetees,
{ choisies: 1, imposees: 0 },
JSON.stringify(reservations),
);
}
});
test("le compte d'une paire tient 65 536 tours", () => {
const R = 65_536;
const instance = normaliser({
participants: [
{ id: 1, nom: 'P1', appartenance: null },
{ id: 2, nom: 'P2', appartenance: null },
],
tables: [{ id: 1, numero: 1, capacite: 2 }],
tours: R,
reservations: [],
contraintes: SANS_CONTRAINTE,
});
// Toutes les cases valent 0 : les deux participants partagent la table
// d'index 0 à chaque tour.
const mesures = mesurer(instance, new Int32Array(2 * R));
assert.equal(mesures.maxRencontresPaire, R);
assert.deepEqual(mesures.rencontres, [1, 1]);
assert.deepEqual(mesures.rencontresRepetees, { choisies: 1, imposees: 0 });
});
test('personne à la même table : aucune rencontre, maximum par paire à 0', () => {
const mesures = mesurerPlan({
appartenance: ['X', 'X'],
capacite: 2,
tours: [
[[1], [2]],
[[2], [1]],
],
});
assert.deepEqual(mesures.rencontres, [0, 0]);
assert.deepEqual(mesures.diversite, [null, null]);
assert.equal(mesures.maxRencontresPaire, 0);
assert.equal(mesures.collisionsCumulees, 0);
// L'effectif d'un groupe compte tous ses membres, qu'ils rencontrent
// quelqu'un ou non.
assert.deepEqual(mesures.parGroupe, [
{ groupe: 'X', effectif: 2, collisionsCumulees: 0, pairesDistinctes: 0, excedent: 0 },
]);
});
test("parGroupe suit l'ordre de instance.groupes, non l'ordre alphabétique", () => {
// instance.groupes range les appartenances par première apparition, en
// parcourant les identifiants croissants : Y, portée par 1, puis X, par
// 2. 1 et 3, tous deux de Y, se rencontrent une fois.
const mesures = mesurerPlan({ appartenance: ['Y', 'X', 'Y'], capacite: 3, tours: [[[1, 2, 3]]] });
assert.deepEqual(mesures.parGroupe, [
{ groupe: 'Y', effectif: 2, collisionsCumulees: 1, pairesDistinctes: 1, excedent: 0 },
{ groupe: 'X', effectif: 1, collisionsCumulees: 0, pairesDistinctes: 0, excedent: 0 },
]);
});
test('sans participant présent : tableaux vides, agrégats à null, compteurs à 0', () => {
const instance = normaliser({
participants: [{ id: 1, nom: 'P1', appartenance: 'X', exclu: true }],
tables: [{ id: 1, numero: 1, capacite: 2 }],
tours: 2,
reservations: [],
contraintes: SANS_CONTRAINTE,
});
assert.deepEqual(mesurer(instance, new Int32Array(0)), {
rencontres: [],
affilies: [],
appartenancesVues: [],
redondance: [],
diversite: [],
toursAssis: [],
retoursChoisis: [],
retoursImposes: [],
aggRencontres: { tous: AUCUN, mobiles: AUCUN, ancres: AUCUN },
aggRedondance: { tous: AUCUN, mobiles: AUCUN, ancres: AUCUN },
totalRedondance: 0,
collisionsCumulees: 0,
pairesDistinctes: 0,
excedentCollisions: 0,
rencontresRepetees: { choisies: 0, imposees: 0 },
maxRencontresPaire: 0,
totalRetoursChoisis: 0,
totalRetoursImposes: 0,
parGroupe: [],
});
});
test("un plan indexé qui n'a pas la forme de l'instance lève RangeError, qui nomme le participant et le tour", () => {
const { instance, tableDe } = preparer({ appartenance: PETITE, capacite: 3, tours: PLAN_PARFAIT });
// Douze participants, quatre tours : 48 cases, ni une de moins ni une de
// trop.
assert.throws(() => mesurer(instance, tableDe.subarray(1)), RangeError);
assert.throws(() => mesurer(instance, Int32Array.of(...tableDe, 0)), RangeError);
// Quatre tables : une case vaut −1 ou un index entier de 0 à 3. Les
// chaînes "0" et "-1" ne sont ni l'un ni l'autre : une comparaison qui
// convertit lirait l'une comme la table d'index 0, l'autre comme la
// réserve. Chaque intrus se pose à la première case, à une case du milieu
// et à la dernière.
for (const intrus of [4, -2, 1.5, '0', '-1']) {
for (const k of [0, 5, 47]) {
const fautif = Array.from(tableDe);
fautif[k] = intrus;
assert.throws(() => mesurer(instance, fautif), RangeError, `case ${k} à ${JSON.stringify(intrus)}`);
}
}
// La case p × R + r est celle du participant d'index p au tour r + 1 : la
// dernière est celle de 12 au tour 4. Le message cite une chaîne entre
// guillemets, pour qu'elle ne se lise pas comme l'index qu'elle contient.
const fautif = Array.from(tableDe);
fautif[47] = '0';
assert.throws(() => mesurer(instance, fautif), {
name: 'RangeError',
message: /participant 12, tour 4, index de table "0" hors de −1\.\.3/,
});
});
test("mesurer ne modifie ni l'instance ni le plan", () => {
const { instance, tableDe } = preparer(PLAN_RESERVATIONS);
const avant = {
tableDe: tableDe.slice(),
fixe: instance.fixe.slice(),
statut: instance.statut.slice(),
groupe: instance.groupe.slice(),
};
mesurer(instance, tableDe);
assert.deepEqual(
{ tableDe, fixe: instance.fixe, statut: instance.statut, groupe: instance.groupe },
avant,
);
});
});

View file

@ -0,0 +1,544 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves d'intégration lourdes du moteur. D'abord la grande démonstration
// (§ 5.5, § 12.10.4, § 15.1) : trois propositions traversent la chaîne
// d'integration.test.js, de rechercher au certificat. La configuration est
// exactement tendue et impose ses planchers, que le diagnostic chiffre avant
// la recherche : 99 retours imposés par les 33 ancrages ; un écart
// d'itinéraire maximal d'au moins 1, porté par au moins 24 mobiles ; un
// certificat « minimum atteint » hors d'atteinte. Les seuils de qualité sont
// posés après mesure, la mesure en commentaire (§ 14.1). Puis la grille de
// propriétés du § 14.12, sur des configurations tirées.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { CATALOGUE } from '../demo/catalogue.js';
import { FLUX, creerPcg32 } from '../demo/prng.js';
import { STATUT, indexerPlan, nombrePlaces, normaliser } from './configuration.js';
import { diagnostiquer } from './diagnostic.js';
import { mesurer } from './indicateurs.js';
import {
ecartsAuPlafondAPriori,
ecartsItineraire,
minimumAtteint,
troisChiffres,
} from './manque.js';
import { plafondsAPriori, plafondsRealises } from './plafond.js';
import {
creerEtat,
defaire,
proposerEtAppliquer,
rechercher,
regenerer,
scoreComplet,
scoreIncremental,
} from './recherche.js';
import { verifierIndicateurs, verifierInvariants } from './verification.js';
// Compte d'arrêt choisi par la mesure. À 1 000 000 mouvements, chacune des
// trois propositions atteint, sur chaque seuil ci-dessous, la meilleure
// valeur que la configuration permette, sans rencontre répétée ni retour
// choisi. Avec cette graine, chaque seuil tient à partir de son propre
// compte :
// - rencontres minimales 24, sur tous comme sur les ancrés : déjà à
// 100 000 ;
// - écart au plafond a priori maximal 1, et rencontres minimales 27 des
// mobiles : dès 250 000 ; à 200 000, ils valent 2 et 26 ;
// - manque maximal 0, aucun retour choisi : dès 350 000 ; à 300 000, une
// proposition garde un manque de 1 et un retour choisi ;
// - aucune collision : dès 450 000 ; à 400 000, les trois propositions en
// portent 8, 1 et 2.
// 1 000 000 vaut ainsi 4 fois le compte où tiennent l'écart au plafond a
// priori et les mobiles, 2,9 fois celui du manque et des retours choisis,
// 2,2 fois celui des collisions. Sur les graines 1 à 8, les vingt-quatre
// propositions tiennent l'écart au plafond a priori et les mobiles dès
// 250 000, le manque et les retours choisis dès 350 000, les collisions dès
// 500 000 ; à 450 000, deux en portent encore 1 et 5.
const REGLAGES = Object.freeze({ graine: 314_159, arret: 1_000_000, nombre: 3 });
// Seuils de qualité, posés après mesure ; chacun est la meilleure valeur
// que la configuration permette.
//
// Rencontres minimales : 24 mesuré à chacune des trois propositions. C'est
// le plafond a priori des quatre animateurs des tables de 7, qu'aucune
// proposition ne dépasse (§ 15.1) : le minimum global ne peut pas le
// dépasser, et l'épreuve exige qu'il l'atteigne. Les deux lignes
// secondaires du § 5.4 se lisent à part : 24 chez les ancrés, que ces
// quatre animateurs gouvernent ; 27 chez les mobiles. Un mobile assis une
// fois à une table de 7 rencontre 27 personnes au plus, une de moins que
// son plafond a priori de 28, et au moins 24 mobiles y passent (§ 15.1) ;
// l'écart au plafond a priori maximal de 1 interdit à chacun d'en
// rencontrer moins. Un agrégat « tous » qui écarterait les ancrés vaudrait
// 27 (§ 5.4).
const RENCONTRES_MIN = 24;
const RENCONTRES_MIN_ANCRES = 24;
const RENCONTRES_MIN_MOBILES = 27;
// Écart au plafond a priori maximal : 1 mesuré à chacune des trois
// propositions. Il ne descend pas sous l'écart d'itinéraire maximal, dont le
// plancher vaut 1 (§ 12.10.4) : ce seuil aussi est la meilleure valeur
// possible.
const ECART_AU_PLAFOND_A_PRIORI_MAX = 1;
// Collisions cumulées : 0 mesuré à chacune des trois propositions, la
// meilleure valeur possible ; le diagnostic n'impose à cette configuration
// aucun plancher de collisions. Une recherche qui ne sépare pas les
// appartenances en laisse de 14 à 20 à chaque proposition.
const COLLISIONS_CUMULEES_MAX = 0;
// Plus grand élément d'une liste non vide.
const plusGrand = (valeurs) => valeurs.reduce((plus, valeur) => Math.max(plus, valeur));
// Nombre de personnes non ancrées dont l'écart d'itinéraire, ecarts[p] dans
// l'ordre canonique, atteint plancher. Le diagnostic en écrit le minimum,
// mobilesAuMoins, à côté de plancher.
function mobilesAuPlancher(instance, ecarts, plancher) {
let compte = 0;
for (let p = 0; p < instance.N; p += 1) {
if (instance.statut[p] !== STATUT.ANCRE && ecarts[p] >= plancher) compte += 1;
}
return compte;
}
// La génération et ses mesures, calculées au premier appel puis partagées
// par les épreuves du fichier : la recherche en est le coût. Chaque
// proposition porte la liste de ses violations ; ses mesures, ses plafonds
// réalisés, ses trois chiffres et ses écarts d'itinéraire ne sont calculés
// que pour un plan sans violation. indexerPlan n'examine ni capacités ni
// réservations et lève à la première faute de forme, là où le vérificateur
// les nomme toutes.
let generation = null;
function genererGrande() {
if (generation !== null) return generation;
const configuration = CATALOGUE.find(({ cle }) => cle === 'grande').construire();
const instance = normaliser(configuration);
const aPriori = plafondsAPriori(instance);
const propositions = rechercher(configuration, REGLAGES).map(({ id, plan }) => {
const violations = verifierInvariants(instance, plan);
if (violations.length > 0) return { id, violations };
const tableDe = indexerPlan(instance, plan);
const mesures = mesurer(instance, tableDe);
const realises = plafondsRealises(instance, tableDe);
return {
id,
violations,
mesures,
realises,
chiffres: troisChiffres(instance, mesures, aPriori, realises),
ecartsItineraire: ecartsItineraire(aPriori, realises),
};
});
generation = { instance, aPriori, diagnostic: diagnostiquer(configuration), propositions };
return generation;
}
// Les propositions de la génération, chacune jugée par verifierInvariants
// avant toute lecture de ses mesures. Un parcours vide ne prouverait rien
// (§ 14.2).
function propositionsValides() {
const { propositions } = genererGrande();
assert.equal(propositions.length, REGLAGES.nombre);
for (const { id, violations } of propositions) {
assert.deepEqual(violations, [], `proposition ${id}`);
}
return propositions;
}
describe('intégration : la grande démonstration de bout en bout (§ 5.5, § 12.10.4, § 15.1)', () => {
test('chaque proposition tient invariants et indicateurs, porte les 99 retours imposés du diagnostic et aucun retour choisi', () => {
const { aPriori, diagnostic } = genererGrande();
// 33 ancrages de 4 tours, 3 retours chacun : attribués aux réservations,
// les mêmes pour toute proposition (§ 5.4).
assert.equal(diagnostic.retoursImposes, 99);
for (const { id, mesures, realises } of propositionsValides()) {
assert.deepEqual(verifierIndicateurs(mesures, aPriori, realises), [], `proposition ${id}`);
assert.equal(mesures.totalRetoursImposes, diagnostic.retoursImposes, `proposition ${id}`);
// Retours choisis : 0 mesuré à chacune des trois propositions, la
// meilleure valeur possible ; avec cette graine, dès 350 000
// mouvements (REGLAGES). Les deux comptes restent séparés, chacun à sa
// valeur : leur somme afficherait 99 retours choisis à chaque
// proposition, une constante où la différence entre propositions ne se
// lit plus (§ 5.4, § 14.10).
assert.equal(mesures.totalRetoursChoisis, 0, `proposition ${id}`);
}
});
test("le plancher du diagnostic tient : écart d'itinéraire maximal ≥ 1, au moins 24 mobiles sous leur plafond a priori", () => {
const { instance, diagnostic } = genererGrande();
// Les tables de 7 offrent 96 sièges-tours mobiles, qu'un mobile n'occupe
// que 4 fois au plus : au moins 24 mobiles y passent, et chacun y perd au
// moins 1 sur son plafond a priori de 28 avant toute rencontre, son écart
// d'itinéraire (§ 12.10.4). Sans ce plancher écrit, les comparaisons
// ci-dessous n'éprouveraient rien.
assert.deepEqual(diagnostic.ecartItineraire, { plancher: 1, mobilesAuMoins: 24 });
const { plancher, mobilesAuMoins } = diagnostic.ecartItineraire;
for (const { id, chiffres, ecartsItineraire: ecarts } of propositionsValides()) {
assert.ok(
chiffres.ecartItineraireMax.tous >= plancher,
`proposition ${id} : écart d'itinéraire maximal ${chiffres.ecartItineraireMax.tous}, plancher ${plancher}`,
);
const mobilesSous = mobilesAuPlancher(instance, ecarts, plancher);
assert.ok(
mobilesSous >= mobilesAuMoins,
`proposition ${id} : ${mobilesSous} mobiles sous leur plafond a priori, au moins ${mobilesAuMoins} attendus`,
);
}
});
test('jamais « minimum atteint » (§ 15.1), même quand chacun atteint son plafond réalisé', () => {
const { aPriori } = genererGrande();
for (const { id, mesures, chiffres } of propositionsValides()) {
// Manque maximal, seuil posé après mesure : 0 à chacune des trois
// propositions, sur les 260 personnes. Chacun rencontre tout ce que son
// itinéraire permet ; un certificat lu sur le seul manque serait
// décerné, et il ne l'est pas : il se lit contre le plafond a priori
// (§ 5.5).
assert.equal(chiffres.manqueMax.tous, 0, `proposition ${id}`);
assert.equal(minimumAtteint(mesures, aPriori), false, `proposition ${id}`);
}
});
test('seuils de qualité posés après mesure : rencontres minimales et leurs lignes secondaires, écart au plafond a priori maximal, collisions cumulées', () => {
const { aPriori } = genererGrande();
for (const { id, mesures } of propositionsValides()) {
const { tous, ancres, mobiles } = mesures.aggRencontres;
assert.equal(tous.min, RENCONTRES_MIN, `proposition ${id} : rencontres minimales`);
assert.equal(ancres.min, RENCONTRES_MIN_ANCRES, `proposition ${id} : rencontres minimales des ancrés`);
assert.equal(mobiles.min, RENCONTRES_MIN_MOBILES, `proposition ${id} : rencontres minimales des mobiles`);
const ecartMax = plusGrand(ecartsAuPlafondAPriori(mesures, aPriori));
assert.ok(
ecartMax <= ECART_AU_PLAFOND_A_PRIORI_MAX,
`proposition ${id} : écart au plafond a priori maximal ${ecartMax}, seuil ${ECART_AU_PLAFOND_A_PRIORI_MAX}`,
);
assert.ok(
mesures.collisionsCumulees <= COLLISIONS_CUMULEES_MAX,
`proposition ${id} : ${mesures.collisionsCumulees} collisions cumulées, seuil ${COLLISIONS_CUMULEES_MAX}`,
);
}
});
});
// Grille de propriétés (§ 14.12). Les quatre démonstrations ont quatre
// tours, chacun y porte une appartenance, aucune n'a de partiellement fixé
// ni d'exclu, et une seule laisse des places vides : la grille tire des
// configurations hors de ce cadre, d'une graine écrite ici. Les propriétés
// éprouvées valent pour tout plan que rend la recherche, quelle qu'en soit
// la qualité : un compte d'arrêt bref suffit. Un historique court fait jouer
// la bascule de la descente dans l'ordre des contraintes, qu'un plancher
// compte ci-dessous.
const GRILLE = Object.freeze({ graine: 1_729, configurations: 300, arret: 3_000, nombre: 2 });
// Une salle sur cinq est trop petite, deux sont tendues, deux ont des places
// vides.
const SALLES = Object.freeze(['tendue', 'tendue', 'places vides', 'places vides', 'trop petite']);
const LIBELLES = Object.freeze(['A', 'B', 'C']);
// Mouvements appliqués où la marche confronte le score tenu à jour au score
// recalculé, après le placement initial ; un mouvement appliqué sur trois est
// défait, comme dans recherche.long.test.js.
const PALIERS = Object.freeze([1, 10, 100, 1_000]);
const DEFAIT_TOUS_LES = 3;
// k entiers distincts de 0 à n − 1, tirés sans remise par le mélange
// partiel de Fisher et Yates.
function tirerSansRemise(rng, n, k) {
const pile = Array.from({ length: n }, (_, i) => i);
for (let i = 0; i < k; i += 1) {
const j = i + rng.borne(n - i);
[pile[i], pile[j]] = [pile[j], pile[i]];
}
return pile.slice(0, k);
}
// Une configuration de la grille, ses réglages de génération, sa salle et le
// nombre de places qui lui manquent, tirés de rng :
// - de 2 à 6 tables de 2 à 6 sièges, de 1 à 5 tours ;
// - N = S personnes présentes dans une salle tendue, S = Σ c_t ; S − v, v de
// 1 à ⌊S / 2⌋, dans une salle à places vides ; S + 1 à S + 3 dans une salle
// trop petite ;
// - chacun une fois sur deux : des ancrés, des réservations d'une partie des
// tours, un ou deux exclus. Une réservation d'une partie des tours fait un
// partiellement fixé quand R ≥ 2, parfois deux fois à la même table, et un
// ancré par la portée « tour » quand R = 1 ;
// - de 1 à 3 appartenances, et des personnes sans appartenance ;
// - des identifiants de participant croissants à trous, des identifiants de
// table dans un ordre quelconque, une liste de participants mélangée ;
// - une réservation d'exclu une fois sur deux, suspendue et sans siège, sa
// table fût-elle pleine (§ 4.4), et une réservation en double une fois sur
// quatre ;
// - chaque contrainte active trois fois sur quatre ; la graine de la
// génération, et un historique de 16 à 64 cases.
// Une réservation de personne présente ne se pose que sur une table où il
// reste un siège à chacun de ses tours : la configuration ne lève jamais
// SURRESERVATION, et normaliser l'accepte toujours.
function tirerConfiguration(rng) {
const T = 2 + rng.borne(5);
const capacites = Array.from({ length: T }, () => 2 + rng.borne(5));
const R = 1 + rng.borne(5);
const S = capacites.reduce((somme, c) => somme + c, 0);
const salle = SALLES[rng.borne(SALLES.length)];
let N = S;
if (salle === 'places vides') N = S - 1 - rng.borne(Math.floor(S / 2));
if (salle === 'trop petite') N = S + 1 + rng.borne(3);
const avecAncres = rng.borne(2) === 0;
const avecToursReserves = rng.borne(2) === 0;
const exclus = rng.borne(2) === 0 ? 1 + rng.borne(2) : 0;
const G = 1 + rng.borne(LIBELLES.length);
const ids = [];
for (let i = 0, id = 0; i < N + exclus; i += 1) {
id += 1 + rng.borne(2);
ids.push(id);
}
const idsExclus = new Set(tirerSansRemise(rng, ids.length, exclus).map((i) => ids[i]));
const participants = ids.map((id) => {
const g = rng.borne(G + 1);
return {
id,
nom: `P${id}`,
appartenance: g < G ? LIBELLES[g] : null,
exclu: idsExclus.has(id),
};
});
const idsTables = [];
for (let t = 0, id = 0; t < T; t += 1) {
id += 1 + rng.borne(3);
idsTables.push(id);
}
rng.melanger(idsTables);
const tables = idsTables.map((id, t) => ({ id, numero: t + 1, capacite: capacites[t] }));
// fixees[t × R + r] : les personnes présentes que les réservations
// assoient à la table t au tour r.
const fixees = new Int32Array(T * R);
const siegeLibre = (t, r) => fixees[t * R + r] < capacites[t];
const tousLesTours = Array.from({ length: R }, (_, r) => r);
const reservations = [];
for (const { id, exclu } of participants) {
const role = rng.borne(6);
if (exclu) {
if (role < 3) {
const table = idsTables[rng.borne(T)];
const portee = role === 0 ? { portee: 'tous' } : { portee: 'tour', tour: 1 + rng.borne(R) };
reservations.push({ participant: id, table, ...portee });
}
} else if (avecAncres && role === 0) {
const t = rng.borne(T);
if (tousLesTours.every((r) => siegeLibre(t, r))) {
for (const r of tousLesTours) fixees[t * R + r] += 1;
reservations.push({ participant: id, table: idsTables[t], portee: 'tous' });
}
} else if (avecToursReserves && role === 1) {
const tours = R === 1 ? 1 : 1 + rng.borne(R - 1);
for (const r of tirerSansRemise(rng, R, tours)) {
const t = rng.borne(T);
if (!siegeLibre(t, r)) continue;
fixees[t * R + r] += 1;
reservations.push({ participant: id, table: idsTables[t], portee: 'tour', tour: r + 1 });
}
}
}
if (reservations.length > 0 && rng.borne(4) === 0) {
reservations.push({ ...reservations[rng.borne(reservations.length)] });
}
rng.melanger(participants);
const contraintes = {
separerAppartenances: rng.borne(4) > 0,
nouveauxVoisins: rng.borne(4) > 0,
nouvelleTable: rng.borne(4) > 0,
varierAppartenances: rng.borne(4) > 0,
};
const reglages = {
graine: rng.suivant(),
arret: GRILLE.arret,
nombre: GRILLE.nombre,
historique: 16 + rng.borne(49),
};
return {
configuration: { participants, tables, tours: R, reservations, contraintes },
reglages,
salle,
placesManquantes: Math.max(0, N - S),
};
}
// Une marche depuis le placement initial de la proposition de graine
// dérivée graine, mouvements tirés du même générateur que sa descente :
// chaque palier ajoute à fautes un désaccord entre le score tenu à jour et le
// score recalculé. Rend vrai quand la marche atteint le dernier palier. Une
// configuration où les mouvements ne s'appliquent pas l'arrête plus tôt :
// celle dont chaque tour n'a de sièges mobiles qu'à une seule table, ou n'y
// a que des places fantômes.
function marcher(nom, instance, graine, fautes) {
const rng = creerPcg32(graine, FLUX.RECHERCHE);
const etat = creerEtat(instance, rng);
const confronter = (appliques) => {
const tenu = scoreIncremental(etat);
const recalcule = scoreComplet(etat);
if (tenu.some((valeur, k) => valeur !== recalcule[k])) {
fautes.push(`${nom}, ${appliques} mouvements : score tenu ${tenu}, recalculé ${recalcule}`);
}
};
confronter(0);
let appliques = 0;
let palier = 0;
for (let essai = 0; palier < PALIERS.length && essai < 100 * PALIERS.at(-1); essai += 1) {
const mouvement = proposerEtAppliquer(etat, rng);
if (mouvement === null) continue;
appliques += 1;
if (appliques % DEFAIT_TOUS_LES === 0) defaire(etat, mouvement);
if (appliques === PALIERS[palier]) {
confronter(appliques);
palier += 1;
}
}
return palier === PALIERS.length;
}
// Ajoute à fautes ce qu'une proposition enfreint : les deux vérificateurs,
// les retours imposés du diagnostic, ses planchers de collisions et d'écart
// d'itinéraire, et sa régénération à partir de sa graine dérivée, de son
// compte d'arrêt et de son historique, sans son plan ni son identifiant. Ses
// mesures ne se lisent que sur un plan sans violation.
function juger(nom, { configuration, instance, aPriori, diagnostic }, proposition, fautes) {
const { graine, arret, historique, plan } = proposition;
const violations = verifierInvariants(instance, plan);
if (violations.length > 0) {
fautes.push(`${nom} : ${JSON.stringify(violations)}`);
return false;
}
const tableDe = indexerPlan(instance, plan);
const mesures = mesurer(instance, tableDe);
const realises = plafondsRealises(instance, tableDe);
const indicateurs = verifierIndicateurs(mesures, aPriori, realises);
if (indicateurs.length > 0) fautes.push(`${nom} : ${JSON.stringify(indicateurs)}`);
if (mesures.totalRetoursImposes !== diagnostic.retoursImposes) {
fautes.push(`${nom} : ${mesures.totalRetoursImposes} retours imposés, ${diagnostic.retoursImposes} au diagnostic`);
}
for (const plancher of diagnostic.collisions) {
const mesure = mesures.parGroupe.find(({ groupe }) => groupe === plancher.groupe);
if (mesure.collisionsCumulees < plancher.plancherCumulees
|| mesure.pairesDistinctes < plancher.plancherPairesDistinctes
|| (plancher.plancherExcedent !== null && mesure.excedent < plancher.plancherExcedent)) {
fautes.push(
`${nom}, groupe ${plancher.groupe} : collisions ${mesure.collisionsCumulees} sur `
+ `${mesure.pairesDistinctes} paires, excédent ${mesure.excedent}, sous le plancher `
+ JSON.stringify(plancher),
);
}
}
if (diagnostic.ecartItineraire !== null) {
const { plancher, mobilesAuMoins } = diagnostic.ecartItineraire;
const { ecartItineraireMax } = troisChiffres(instance, mesures, aPriori, realises);
const mobilesSous = mobilesAuPlancher(instance, ecartsItineraire(aPriori, realises), plancher);
if (ecartItineraireMax.tous < plancher || mobilesSous < mobilesAuMoins) {
fautes.push(
`${nom} : écart d'itinéraire maximal ${ecartItineraireMax.tous}, ${mobilesSous} mobiles `
+ `à au moins ${plancher}, sous le plancher ${JSON.stringify(diagnostic.ecartItineraire)}`,
);
}
}
if (JSON.stringify(regenerer(configuration, { graine, arret, historique })) !== JSON.stringify(plan)) {
fautes.push(`${nom} : la régénération rend un autre plan`);
}
// Une proposition a basculé dans l'ordre des contraintes exactement quand
// son plan atteint un écart au plafond a priori maximal nul.
return Math.max(...ecartsAuPlafondAPriori(mesures, aPriori)) === 0;
}
// Planchers de la grille (§ 14.2) : ce que la boucle doit avoir éprouvé pour
// que ses propriétés disent quelque chose. Un générateur qui cesserait de
// tirer l'un de ces cas laisserait les propriétés vraies sans les éprouver.
// Les comptes lisent donc ce que la boucle a éprouvé, l'instance, le
// diagnostic ou la marche, et non l'étiquette que le générateur a tirée.
// Seules les salles trop petites se comptent sur leur étiquette : une salle
// ainsi étiquetée qui ne l'est pas fait échouer la boucle, qui exige son
// refus. Chaque plancher est posé sous le compte que donne la graine écrite,
// en commentaire.
const PLANCHERS = Object.freeze([
['configurations éprouvées', 'eprouvees', 200], // 230 sur 300
['salles trop petites refusées', 'tropPetites', 50], // 70
['salles tendues', 'tendues', 90], // 120
['salles à places vides', 'placesVides', 80], // 110
['avec ancrés', 'ancres', 90], // 122
['avec partiellement fixés', 'partiellementFixes', 60], // 87
['avec exclus', 'exclus', 90], // 122
['à un seul tour', 'unTour', 30], // 47
['avec retours imposés', 'retoursImposes', 70], // 103
['avec plancher de collisions', 'plancherCollisions', 120], // 173
["avec plancher d'excédent", 'plancherExcedent', 10], // 15
["avec plancher d'écart d'itinéraire", 'plancherEcartItineraire', 35], // 50
['marches au dernier palier', 'marches', 400], // 460, une par proposition
["propositions qui basculent dans l'ordre des contraintes", 'bascules', 60], // 86
]);
describe('intégration : grille de propriétés sur des configurations tirées (§ 14.12)', () => {
test(`${GRILLE.configurations} configurations : chaque proposition tient les deux vérificateurs, les retours imposés et les planchers du diagnostic, et se régénère ; le score tenu à jour égale le recalcul le long d'une marche tirée de sa graine dérivée ; une salle trop petite est refusée du nombre de places du diagnostic`, () => {
const rng = creerPcg32(GRILLE.graine, FLUX.DEMO);
const comptes = {
eprouvees: 0,
tropPetites: 0,
tendues: 0,
placesVides: 0,
ancres: 0,
partiellementFixes: 0,
exclus: 0,
unTour: 0,
retoursImposes: 0,
plancherCollisions: 0,
plancherExcedent: 0,
plancherEcartItineraire: 0,
marches: 0,
bascules: 0,
};
const fautes = [];
for (let c = 1; c <= GRILLE.configurations; c += 1) {
const { configuration, reglages, salle, placesManquantes } = tirerConfiguration(rng);
const nom = `configuration ${c}`;
const instance = normaliser(configuration);
const diagnostic = diagnostiquer(configuration);
if (diagnostic.placesManquantes !== placesManquantes) {
fautes.push(`${nom} : ${diagnostic.placesManquantes} places manquantes au diagnostic, ${placesManquantes} tirées`);
}
if (salle === 'trop petite') {
comptes.tropPetites += 1;
try {
rechercher(configuration, reglages);
fautes.push(`${nom} : salle trop petite acceptée`);
} catch (erreur) {
if (erreur.code !== 'PLACES_MANQUANTES'
|| erreur.details.placesManquantes !== diagnostic.placesManquantes) {
fautes.push(`${nom} : ${erreur.message}`);
}
}
continue;
}
comptes.eprouvees += 1;
if (nombrePlaces(instance) === instance.N) comptes.tendues += 1;
else comptes.placesVides += 1;
if (instance.k > 0) comptes.ancres += 1;
if (instance.statut.includes(STATUT.PARTIELLEMENT_FIXE)) comptes.partiellementFixes += 1;
if (instance.exclus.size > 0) comptes.exclus += 1;
if (instance.R === 1) comptes.unTour += 1;
if (diagnostic.retoursImposes > 0) comptes.retoursImposes += 1;
if (diagnostic.collisions.length > 0) comptes.plancherCollisions += 1;
if (diagnostic.collisions.some(({ plancherExcedent }) => plancherExcedent !== null)) {
comptes.plancherExcedent += 1;
}
if (diagnostic.ecartItineraire !== null) comptes.plancherEcartItineraire += 1;
const contexte = { configuration, instance, aPriori: plafondsAPriori(instance), diagnostic };
const propositions = rechercher(configuration, reglages);
if (propositions.length !== GRILLE.nombre) fautes.push(`${nom} : ${propositions.length} propositions`);
for (const proposition of propositions) {
const nomProposition = `${nom}, proposition ${proposition.id}`;
if (juger(nomProposition, contexte, proposition, fautes)) comptes.bascules += 1;
if (marcher(nomProposition, instance, proposition.graine, fautes)) comptes.marches += 1;
}
}
// Les vingt premières fautes suffisent à désigner un défaut ; le message
// en donne le nombre.
assert.deepEqual(fautes.slice(0, 20), [], `${fautes.length} fautes`);
const sousLePlancher = PLANCHERS
.filter(([, cle, plancher]) => comptes[cle] < plancher)
.map(([libelle, cle, plancher]) => `${libelle} : ${comptes[cle]}, plancher ${plancher}`);
assert.deepEqual(sousLePlancher, [], JSON.stringify(comptes));
});
});

View file

@ -0,0 +1,224 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuve d'intégration du moteur, de la configuration au classement, sur la
// petite démonstration et sa variante conflit (§ 5.5, § 5.6, § 5.7,
// § 15.3). Chaque proposition de rechercher traverse la chaîne entière :
// verifierInvariants juge son plan ; mesurer et plafondsRealises le
// mesurent ; verifierIndicateurs éprouve la ligne de chacun contre le
// plafond a priori ; classer ordonne les propositions, et minimumAtteint lit
// le certificat de la première ; diagnostiquer fournit les planchers que la
// meilleure respecte. Chaque quantité vient de son module (§ 13.2) :
// l'épreuve n'en recalcule aucune.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js';
import { CRITERES, classer } from './classement.js';
import { indexerPlan, normaliser } from './configuration.js';
import { diagnostiquer } from './diagnostic.js';
import { mesurer } from './indicateurs.js';
import { minimumAtteint } from './manque.js';
import { plafondsAPriori, plafondsRealises } from './plafond.js';
import { rechercher } from './recherche.js';
import { verifierIndicateurs, verifierInvariants } from './verification.js';
// Réglages de la génération : une graine fixée, écrite ici (§ 15.5, point 2).
// À 100 000 mouvements, avec cette graine, les trois propositions de chaque
// démonstration rencontrent 8 pour les douze, sans rencontre répétée, et ne
// portent que les collisions que le diagnostic impose : aucune dans la
// petite démonstration, 4 dans sa variante. Une proposition de la variante
// garde 15 retours choisis, les cinq autres aucun. Les assertions des
// épreuves tiennent avec cette graine dès 80 000 mouvements, soit une marge
// de 1,25 ; en deçà, les trois propositions de la petite démonstration
// gardent des retours choisis. La marge est celle de cette graine : sur les
// graines 1 à 64, les assertions tiennent pour 43 à 100 000, pour toutes à
// 200 000.
//
// Le compte pèse sur la relance sous surveillance (§ 14.14) : toute
// modification du moteur, ou du générateur que lit le catalogue, rejoue ce
// fichier.
const REGLAGES = Object.freeze({ graine: 314_159, arret: 100_000, nombre: 3 });
// La même génération, arrêtée au premier mouvement. Aucune de ses
// propositions n'atteint son plafond a priori : avec cette graine, leurs
// écarts au plafond a priori maximaux valent 2, 3 et 3, et sur les graines
// 1 à 64, aucune proposition brève ne l'atteint non plus.
const REGLAGES_BREVES = Object.freeze({ ...REGLAGES, arret: 1 });
// Configuration neuve d'une démonstration du catalogue.
const demo = (cle) => CATALOGUE.find((entree) => entree.cle === cle).construire();
// Génère les propositions d'une configuration et les fait traverser la
// chaîne. Chaque plan est jugé par verifierInvariants avant d'être indexé :
// indexerPlan n'examine ni capacités ni réservations et lève à la première
// faute de forme, là où le vérificateur les nomme toutes. Vient ensuite la
// ligne N − 1 ≥ a priori ≥ réalisé ≥ rencontres de chacun. decalage
// s'ajoute aux identifiants que rend rechercher, comme l'appelant qui
// accumule des générations donne à chaque proposition un identifiant unique
// (§ 5.7). Rend le plafond a priori de chacun, et les propositions dans
// l'ordre de leurs identifiants, avec leurs mesures.
function generer(configuration, etiquette, reglages, decalage = 0) {
const instance = normaliser(configuration);
const aPriori = plafondsAPriori(instance);
const propositions = rechercher(configuration, reglages).map(({ id, plan }) => {
const nom = `${etiquette}, proposition ${id + decalage}`;
assert.deepEqual(verifierInvariants(instance, plan), [], nom);
const tableDe = indexerPlan(instance, plan);
const mesures = mesurer(instance, tableDe);
const realises = plafondsRealises(instance, tableDe);
assert.deepEqual(verifierIndicateurs(mesures, aPriori, realises), [], nom);
return { id: id + decalage, mesures };
});
// Un parcours vide ne prouverait rien (§ 14.2).
assert.equal(propositions.length, reglages.nombre, etiquette);
return { aPriori, propositions };
}
// Le classement de propositions mesurées sur une même configuration, et
// celle qu'il met en tête.
function classerPropositions({ aPriori, propositions }) {
const classement = classer(
propositions.map(({ id, mesures }) => ({ id, mesures, plafondsAPriori: aPriori })),
);
const premiere = propositions.find(({ id }) => id === classement.ordre[0]);
return { classement, premiere };
}
// La génération de la variante conflit aux réglages de l'épreuve, calculée
// au premier appel puis partagée par les deux épreuves qui la lisent : la
// recherche en est le coût.
let variante = null;
function genererVariante() {
variante ??= generer(demo('petite-conflit'), 'petite-conflit', REGLAGES);
return variante;
}
// Le plan écrit du § 15.3 fait visiter les quatre tables à chacun : aucun
// retour choisi. Le classement ne lit pas les retours (§ 5.7) : entre
// propositions égales sur ses cinq critères, l'identifiant désigne la
// première, qu'elle garde des retours ou non. L'épreuve exige donc le zéro
// d'une proposition au moins, la preuve que la recherche tient compte de
// nouvelleTable.
function exigerUneSansRetourChoisi(etiquette, propositions) {
const retours = propositions.map(({ mesures }) => mesures.totalRetoursChoisis);
assert.ok(retours.includes(0), `${etiquette} : retours choisis par proposition ${retours.join(', ')}`);
}
describe('intégration : de la configuration au classement (§ 5.5, § 5.7, § 15.3)', () => {
test('petite : chaque proposition tient invariants et indicateurs et ne porte aucune collision, une au moins aucun retour choisi ; la première du classement porte « minimum atteint »', () => {
const generation = generer(demo('petite'), 'petite', REGLAGES);
const { classement, premiere } = classerPropositions(generation);
// Le plafond a priori est connu : aucun critère n'est sauté, et le
// certificat se lit contre lui (§ 5.5).
assert.deepEqual(classement.criteresAppliques, [...CRITERES]);
assert.equal(classement.critereSaute, null);
assert.equal(minimumAtteint(premiere.mesures, generation.aPriori), true);
// Le certificat ne lit que les rencontres : il se décerne aussi à un plan
// qui réunit des collègues. Chaque appartenance de quatre membres peut
// se répartir sur les quatre tables à chaque tour, comme dans le plan
// écrit du § 15.3 : aucune proposition ne réunit de collègues, et le
// compteur de collisions se mesure à zéro (§ 15.3).
for (const { id, mesures } of generation.propositions) {
assert.equal(mesures.collisionsCumulees, 0, `proposition ${id}`);
}
exigerUneSansRetourChoisi('petite', generation.propositions);
});
test('petite-conflit : chaque proposition porte les 4 collisions du plancher du diagnostic, une au moins aucun retour choisi ; la meilleure respecte les planchers, rencontre 8 pour les douze, sur 4 paires, excédent 0', () => {
const configuration = demo('petite-conflit');
// Le groupe de cinq ne se répartit pas sur quatre tables : au moins une
// collision par tour, 4 cumulées sur les 4 tours, portées par une paire
// distincte au moins (§ 15.3). Le diagnostic les chiffre ; une liste vide
// laisserait la boucle ci-dessous sans rien éprouver.
const { collisions } = diagnostiquer(configuration);
assert.deepEqual(
collisions.map(({ plancherCumulees, plancherPairesDistinctes }) => [
plancherCumulees,
plancherPairesDistinctes,
]),
[[4, 1]],
);
const generation = genererVariante();
const { premiere } = classerPropositions(generation);
// Tout plan valide respecte ces planchers : ils éprouvent l'accord du
// diagnostic et de la mesure, non la recherche.
for (const plancher of collisions) {
const mesure = premiere.mesures.parGroupe.find(({ groupe }) => groupe === plancher.groupe);
assert.ok(
mesure.collisionsCumulees >= plancher.plancherCumulees,
`${plancher.groupe} : ${mesure.collisionsCumulees} collisions cumulées, plancher ${plancher.plancherCumulees}`,
);
assert.ok(
mesure.pairesDistinctes >= plancher.plancherPairesDistinctes,
`${plancher.groupe} : ${mesure.pairesDistinctes} paires distinctes, plancher ${plancher.plancherPairesDistinctes}`,
);
}
assert.deepEqual(premiere.mesures.rencontres, Array(12).fill(8));
// La recherche se juge à ses collisions : chaque proposition atteint le
// plancher des collisions cumulées sans le dépasser. La meilleure les
// porte sur 4 paires distinctes, comme le plan écrit du § 15.3 (épreuve
// suivante).
for (const { id, mesures } of generation.propositions) {
assert.equal(mesures.collisionsCumulees, collisions[0].plancherCumulees, `proposition ${id}`);
}
assert.equal(premiere.mesures.pairesDistinctes, 4);
// Deux voisins par tour, quatre tours : qui rencontre 8 personnes n'en
// revoit aucune. Chaque paire se réunit donc une fois au plus, et
// l'excédent 0 découle de 8 pour les douze. Il éprouve mesurer, non la
// recherche.
assert.equal(premiere.mesures.excedentCollisions, 0);
exigerUneSansRetourChoisi('petite-conflit', generation.propositions);
});
test('le plan écrit du § 15.3, mesuré sur la variante conflit : 8 pour les douze, 4 collisions sur 4 paires, excédent 0', () => {
// Le témoin de l'épreuve précédente : la personne passée de C à A
// retrouve un membre de A à chacun des quatre tours, chaque fois un
// autre. Ce que l'épreuve exige de la recherche est donc atteignable.
const instance = normaliser(demo('petite-conflit'));
const mesures = mesurer(instance, indexerPlan(instance, PLAN_PARFAIT_PETITE));
assert.deepEqual(mesures.rencontres, Array(12).fill(8));
assert.equal(mesures.collisionsCumulees, 4);
assert.equal(mesures.pairesDistinctes, 4);
assert.equal(mesures.excedentCollisions, 0);
});
test('petite-conflit, deux générations accumulées : classer met la plus longue en tête, numérotée après la brève, et départage ses propositions égales par identifiant croissant (§ 5.7, § 15.5)', () => {
// La génération brève prend les identifiants 1 à 3, la longue 4 à 6 :
// l'ordre attendu contredit l'ordre des identifiants, et seul un
// classement qui applique ses critères peut le rendre.
const breve = generer(demo('petite-conflit'), 'petite-conflit, génération brève', REGLAGES_BREVES);
const variante = genererVariante();
const longue = {
aPriori: variante.aPriori,
propositions: variante.propositions.map(({ id, mesures }) => ({ id: id + REGLAGES.nombre, mesures })),
};
// Ce que l'ordre départage, mesuré avant d'être lu. Chaque proposition
// longue atteint son plafond a priori, et les trois sont égales sur les
// quatre autres critères du classement ; aucune brève n'atteint le sien.
// Sur une même population, la redondance moyenne ordonne comme la somme
// que lit le classement.
const autresCriteres = ({ mesures }) => [
mesures.excedentCollisions,
mesures.collisionsCumulees,
mesures.rencontresRepetees.choisies,
mesures.aggRedondance.tous.moyenne,
];
for (const proposition of longue.propositions) {
const nom = `proposition ${proposition.id}`;
assert.equal(minimumAtteint(proposition.mesures, longue.aPriori), true, nom);
assert.deepEqual(autresCriteres(proposition), autresCriteres(longue.propositions[0]), nom);
}
for (const { id, mesures } of breve.propositions) {
assert.equal(minimumAtteint(mesures, breve.aPriori), false, `proposition ${id}`);
}
// Le premier critère renvoie les brèves derrière les longues ; les
// longues, égales sur les cinq, se départagent par identifiant croissant
// (§ 15.5, point 4).
const { classement } = classerPropositions({
aPriori: longue.aPriori,
propositions: [...breve.propositions, ...longue.propositions],
});
assert.deepEqual(classement.ordre.slice(0, REGLAGES.nombre), [4, 5, 6]);
assert.deepEqual([...classement.ordre.slice(REGLAGES.nombre)].sort((a, b) => a - b), [1, 2, 3]);
});
});

333
src/moteur/manque.js Normal file
View file

@ -0,0 +1,333 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le manque, et ce qui s'en lit (§ 5.5, § 12.10). Chaque personne porte quatre
// quantités, rangées sur une ligne (§ 12.10.5) :
//
// N − 1 ≥ plafond a priori ≥ plafond réalisé ≥ rencontres
//
// Trois différences s'en lisent, sous les noms du glossaire du § 5.5 :
// manque = plafond réalisé − rencontres ; écart d'itinéraire = plafond a
// priori − plafond réalisé ; écart au plafond a priori = plafond a priori −
// rencontres, la somme des deux premières. Chacune a sa fonction : manques,
// ecartsItineraire, ecartsAuPlafondAPriori. Chacune est une soustraction sur
// des tableaux que mesurer (indicateurs.js) et plafond.js ont déjà calculés :
// aucune fonction ne relit le plan ni les paires qu'il réunit, et aucune ne
// modifie ce qu'elle reçoit. Tout tableau par personne suit l'ordre
// canonique, celui de instance.ids.
//
// Les agrégats portent sur les trois populations du § 5.4 : tous, les
// mobiles — partiellement fixés compris — et les ancrés, que forme
// replierParPopulation (configuration.js). Une population vide rend null,
// jamais 0.
//
// Le plafond a priori vaut null quand il est inconnu (§ 12.10.9) : seul null
// le dit. undefined, la valeur d'un champ manquant, lève TypeError au lieu
// de passer pour inconnu. Une liste qui n'a pas une case par personne lève
// RangeError : elle décrit une autre population.
import { replierParPopulation } from './configuration.js';
/**
* @typedef {{tous: number|null, mobiles: number|null, ancres: number|null}} ParPopulation
* un chiffre par population ; null pour une population vide
*
* @typedef {Object} TroisChiffres les trois chiffres du § 12.10.4
* @property {ParPopulation} manqueMax le plus grand manque
* @property {ParPopulation} effectifManque personnes dont le manque vaut au
* moins 1 ; 0 sur une population
* non vide sans manque
* @property {ParPopulation|null} ecartItineraireMax le plus grand écart
* d'itinéraire ; null quand le plafond a priori est inconnu
*
* @typedef {{id: number, manque: number, plafondRealise: number}} RangProfil
* la personne d'un rang du profil, désignée par son identifiant
*
* @typedef {Object} Comparaison
* @property {boolean} comparable
* @property {number} [rangsA] rangs où A sert mieux
* @property {number} [rangsB] rangs où B sert mieux
* @property {number} [egalite] rangs à manques égaux
* @property {number|null} [rangBascule] à partir de 1
* @property {'A'|'B'|null} [domine]
*/
// Lève TypeError quand valeurs n'est pas une liste, faute de longueur
// numérique ; attendu nomme ce que le paramètre nom admet.
function exigerListe(valeurs, nom, attendu = 'liste') {
if (typeof valeurs?.length !== 'number') throw new TypeError(`${nom} : ${attendu} attendue`);
}
// Lève TypeError quand valeurs n'est pas une liste, et RangeError quand elle
// n'a pas N cases.
function exigerLongueur(valeurs, N, nom) {
exigerListe(valeurs, nom);
if (valeurs.length !== N) {
throw new RangeError(`${nom} : ${valeurs.length} cases, ${N} attendues`);
}
}
// Lève TypeError quand plafondsAPriori n'est ni null ni une liste, et
// RangeError quand c'est une liste qui n'a pas N cases.
function exigerAPriori(plafondsAPriori, N) {
if (plafondsAPriori === null) return;
exigerListe(plafondsAPriori, 'plafondsAPriori', 'liste ou null');
exigerLongueur(plafondsAPriori, N, 'plafondsAPriori');
}
// gauche[p] − droite[p] pour chaque personne, deux listes de même longueur.
function soustraire(gauche, droite) {
const differences = [];
for (let p = 0; p < gauche.length; p += 1) differences.push(gauche[p] - droite[p]);
return differences;
}
// Replie valeurs[p] sur les trois populations (replierParPopulation).
// replier(cumul, valeur) rend le cumul suivant ; cumul vaut null au premier
// membre d'une population, et une population sans membre reste à null.
const parPopulation = (valeurs, statut, replier) =>
replierParPopulation(valeurs, statut, null, replier);
// Le plus grand des valeurs repliées.
const plusGrand = (cumul, valeur) => (cumul === null || valeur > cumul ? valeur : cumul);
// Le nombre de valeurs repliées qui valent au moins 1.
const compterAuMoinsUn = (cumul, valeur) => (cumul ?? 0) + (valeur >= 1 ? 1 : 0);
/**
* Manque de chacun (§ 12.10.2) : plafond réalisé − rencontres, en personnes,
* dans l'ordre des deux listes. Un manque négatif est le signe d'un calcul
* faux, que verifierIndicateurs signale (DEPASSEMENT) ; manques le rend tel
* quel.
*
* Lève TypeError quand l'un des deux arguments n'est pas une liste, et
* RangeError quand les deux listes n'ont pas la même longueur.
*
* @param {ArrayLike<number>} plafondsRealises
* @param {ArrayLike<number>} rencontres
* @returns {number[]}
*/
export function manques(plafondsRealises, rencontres) {
exigerListe(plafondsRealises, 'plafondsRealises');
exigerLongueur(rencontres, plafondsRealises.length, 'rencontres');
return soustraire(plafondsRealises, rencontres);
}
/**
* Écart d'itinéraire de chacun (§ 5.5, § 12.10.4) : plafond a priori −
* plafond réalisé, ce que l'itinéraire de la proposition retire avant toute
* rencontre. Une personne en réserve à plusieurs tours le porte quand son
* manque est nul (§ 12.6). Son plus grand est le troisième des trois chiffres.
* null quand le plafond a priori est inconnu.
*
* Lève TypeError quand plafondsRealises n'est pas une liste, ou que
* plafondsAPriori n'est ni une liste ni null, et RangeError quand
* plafondsAPriori n'a pas une case par élément de plafondsRealises.
*
* @param {ArrayLike<number>|null} plafondsAPriori
* @param {ArrayLike<number>} plafondsRealises
* @returns {number[]|null}
*/
export function ecartsItineraire(plafondsAPriori, plafondsRealises) {
exigerListe(plafondsRealises, 'plafondsRealises');
exigerAPriori(plafondsAPriori, plafondsRealises.length);
return plafondsAPriori === null ? null : soustraire(plafondsAPriori, plafondsRealises);
}
/**
* Écart au plafond a priori de chacun (§ 5.5) : plafond a priori −
* rencontres, soit le manque plus l'écart d'itinéraire. Le certificat de
* « minimum atteint » le lit, et son plus grand ouvre le classement (§ 5.7).
* null quand le plafond a priori est inconnu.
*
* Lève TypeError quand mesures.rencontres n'est pas une liste, ou que
* plafondsAPriori n'est ni une liste ni null, et RangeError quand
* plafondsAPriori n'a pas une case par élément de mesures.rencontres.
*
* @param {{rencontres: ArrayLike<number>}} mesures seule la liste rencontres
* est lue
* @param {ArrayLike<number>|null} plafondsAPriori
* @returns {number[]|null}
*/
export function ecartsAuPlafondAPriori(mesures, plafondsAPriori) {
const { rencontres } = mesures;
exigerListe(rencontres, 'mesures.rencontres');
exigerAPriori(plafondsAPriori, rencontres.length);
return plafondsAPriori === null ? null : soustraire(plafondsAPriori, rencontres);
}
/**
* Les trois chiffres de la page de qualité (§ 12.10.4), chacun sur les trois
* populations du § 5.4 :
* 1. manqueMax, le plus grand manque ;
* 2. effectifManque, le nombre de personnes dont le manque vaut au moins 1 :
* 0 sur une population non vide sans manque, un zéro mesuré ;
* 3. ecartItineraireMax, le plus grand écart d'itinéraire, plafond a priori
* − plafond réalisé, tel que ecartsItineraire le rend. Quand le plafond
* a priori est inconnu, ce chiffre vaut null tout entier, et non par
* population : il s'écrit « inconnu », non « — » (§ 12.10.9).
* Une population vide vaut null dans chacun.
*
* Le troisième chiffre voit ce que les deux premiers ne voient pas : une
* personne assise à peu de tours a un plafond réalisé bas, un manque nul, et
* un grand écart d'itinéraire.
*
* Lève RangeError quand mesures.rencontres, plafondsRealises ou
* plafondsAPriori n'a pas une case par participant de l'instance ; TypeError
* quand l'une d'elles n'est pas une liste, plafondsAPriori pouvant valoir
* null.
*
* @param {import('./types.js').Instance} instance N et statut sont lus
* @param {{rencontres: ArrayLike<number>}} mesures seule la liste rencontres
* est lue
* @param {ArrayLike<number>|null} plafondsAPriori
* @param {ArrayLike<number>} plafondsRealises
* @returns {TroisChiffres}
*/
export function troisChiffres(instance, mesures, plafondsAPriori, plafondsRealises) {
const { N, statut } = instance;
// Ces gardes confrontent chaque liste à l'instance. Celles de manques et
// d'ecartsItineraire ne comparent les listes qu'entre elles, et laissent
// passer trois listes accordées mais mesurées sur une autre population,
// celles d'une proposition mesurée avant une exclusion par exemple. La case
// p n'y désigne plus la personne p de l'instance, et parPopulation lirait
// sans bruit le statut d'une autre personne, ou au-delà de N.
exigerLongueur(mesures.rencontres, N, 'mesures.rencontres');
exigerLongueur(plafondsRealises, N, 'plafondsRealises');
exigerAPriori(plafondsAPriori, N);
const manque = manques(plafondsRealises, mesures.rencontres);
const ecarts = ecartsItineraire(plafondsAPriori, plafondsRealises);
return {
manqueMax: parPopulation(manque, statut, plusGrand),
effectifManque: parPopulation(manque, statut, compterAuMoinsUn),
ecartItineraireMax: ecarts === null ? null : parPopulation(ecarts, statut, plusGrand),
};
}
/**
* Profil de manque d'une proposition (§ 12.10.6) : une entrée par
* participant, triée par manque décroissant, puis par plafond réalisé
* croissant — à manque égal, la personne au plafond le plus bas,
* proportionnellement la plus privée, d'abord (§ 12.10.3) —, puis par
* identifiant croissant. Deux participants n'ont jamais le même identifiant :
* la clé ordonne tout, sans dépendre de l'ordre reçu.
*
* Lève RangeError quand manques ou plafondsRealises n'a pas une case par
* participant.
*
* @param {import('./types.js').Instance} instance N et ids sont lus
* @param {ArrayLike<number>} manques ordre canonique
* @param {ArrayLike<number>} plafondsRealises ordre canonique
* @returns {RangProfil[]}
*/
export function profilManque(instance, manques, plafondsRealises) {
const { N, ids } = instance;
exigerLongueur(manques, N, 'manques');
exigerLongueur(plafondsRealises, N, 'plafondsRealises');
const profil = [];
for (let p = 0; p < N; p += 1) {
profil.push({ id: ids[p], manque: manques[p], plafondRealise: plafondsRealises[p] });
}
return profil.sort(
(a, b) => b.manque - a.manque || a.plafondRealise - b.plafondRealise || a.id - b.id,
);
}
// Vrai quand les deux profils portent les mêmes identifiants, chacun autant
// de fois : leurs listes d'identifiants, triées, coïncident.
function memesPersonnes(profilA, profilB) {
if (profilA.length !== profilB.length) return false;
const croissants = (profil) => profil.map(({ id }) => id).sort((x, y) => x - y);
const idsB = croissants(profilB);
return croissants(profilA).every((id, i) => id === idsB[i]);
}
/**
* Compare deux profils de manque rang par rang (§ 12.10.6, § 12.10.7). Chaque
* profil vient de profilManque et suit son propre ordre : le rang i désigne
* en général deux personnes différentes, et la comparaison ne dit rien d'une
* personne.
*
* Deux profils qui ne portent pas les mêmes identifiants rendent
* { comparable: false } : leurs rangs ne se correspondent pas (§ 12.6).
* Sinon, au rang i, la proposition au plus petit manque sert mieux, et la
* comparaison porte :
* - rangsA, rangsB, egalite : les rangs où A sert mieux, où B sert mieux, où
* les deux manques sont égaux ;
* - rangBascule : quand le signe de manqueA(i) − manqueB(i), les égalités
* omises, change une fois et une seule, le rang, compté à partir de 1, où
* la proposition qui servait moins bien commence à servir strictement
* mieux. Avant lui, elle ne sert mieux à aucun rang ; à partir de lui,
* l'autre ne sert plus mieux à aucun. null quand le signe ne change pas, ou
* change plusieurs fois ;
* - domine : 'A' quand A sert au moins aussi bien que B à chaque rang et
* strictement mieux à l'un d'eux, 'B' dans le cas symétrique, null sinon.
* Deux profils égaux à chaque rang ne se dominent pas.
* Un parcours des rangs, après la comparaison des identifiants, suffit.
*
* @param {RangProfil[]} profilA
* @param {RangProfil[]} profilB
* @returns {Comparaison}
*/
export function comparerProfils(profilA, profilB) {
if (!memesPersonnes(profilA, profilB)) return { comparable: false };
let rangsA = 0;
let rangsB = 0;
let egalite = 0;
// Signe du dernier rang à manques différents : −1 quand A y sert mieux, +1
// quand B y sert mieux, 0 avant le premier.
let signe = 0;
let changements = 0;
let bascule = null;
for (let i = 0; i < profilA.length; i += 1) {
const difference = profilA[i].manque - profilB[i].manque;
if (difference === 0) {
egalite += 1;
continue;
}
const signeRang = difference < 0 ? -1 : 1;
if (signeRang < 0) rangsA += 1;
else rangsB += 1;
if (signe !== 0 && signeRang !== signe) {
changements += 1;
if (changements === 1) bascule = i + 1;
}
signe = signeRang;
}
let domine = null;
if (rangsA > 0 && rangsB === 0) domine = 'A';
if (rangsB > 0 && rangsA === 0) domine = 'B';
return {
comparable: true,
rangsA,
rangsB,
egalite,
rangBascule: changements === 1 ? bascule : null,
domine,
};
}
/**
* Certificat de « minimum atteint » (§ 5.5) : vrai quand chacun atteint son
* plafond a priori, son écart au plafond a priori valant 0 ; faux sinon. Le
* certificat se lit contre le plafond a priori, jamais sur le seul manque :
* une proposition qui assoit chacun sur un itinéraire bas atteint partout son
* plafond réalisé, et ne prouve rien.
*
* null quand le plafond a priori est inconnu, le certificat restant hors
* d'atteinte (§ 12.6), et sur une population vide, où il n'y a personne à
* servir (§ 5.4).
*
* Lève TypeError quand mesures.rencontres n'est pas une liste, ou que
* plafondsAPriori n'est ni une liste ni null, et RangeError quand
* plafondsAPriori n'a pas une case par élément de mesures.rencontres.
*
* @param {{rencontres: ArrayLike<number>}} mesures seule la liste rencontres
* est lue
* @param {ArrayLike<number>|null} plafondsAPriori
* @returns {boolean|null}
*/
export function minimumAtteint(mesures, plafondsAPriori) {
const ecarts = ecartsAuPlafondAPriori(mesures, plafondsAPriori);
if (ecarts === null || ecarts.length === 0) return null;
return ecarts.every((ecart) => ecart === 0);
}

705
src/moteur/manque.test.js Normal file
View file

@ -0,0 +1,705 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du manque et de ce qui s'en lit (§ 5.5, § 12.10). Chaque valeur
// attendue est écrite en clair : recopiée de la spécification, ou comptée à
// la main sur le plan qui la précède, jamais calculée par le module éprouvé.
// Un plan s'écrit par identifiants et passe par normaliser et indexerPlan
// comme un plan enregistré ; ses rencontres et ses plafonds viennent de
// indicateurs.js et de plafond.js, éprouvés à part. Quand une épreuve les
// recopie en clair, ce sont les termes de la ligne de décomposition
// (§ 12.10.5) dont ses chiffres sont les différences.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js';
import { indexerPlan, normaliser } from './configuration.js';
import { mesurer } from './indicateurs.js';
import {
comparerProfils,
ecartsAuPlafondAPriori,
ecartsItineraire,
manques,
minimumAtteint,
profilManque,
troisChiffres,
} from './manque.js';
import { plafondsAPriori, plafondsRealises } from './plafond.js';
const SANS_CONTRAINTE = {
separerAppartenances: false,
nouveauxVoisins: false,
nouvelleTable: false,
varierAppartenances: false,
};
// Un chiffre sur trois populations vides (§ 5.4) : null, jamais 0.
const AUCUN = { tous: null, mobiles: null, ancres: null };
// Huit pour chacun des douze participants de la petite démonstration : leur
// plafond a priori (§ 15.3), et leur plafond réalisé sur tout plan qui
// remplit les quatre tables à chaque tour.
const HUIT = Object.freeze(new Array(12).fill(8));
// Ce qui n'est pas une liste, pour les épreuves des gardes : un nombre plutôt
// qu'undefined. Lire undefined.length lève TypeError, garde ou non ; lire
// (8).length rend undefined sans lever, et seule une garde de liste lève
// alors TypeError. L'épreuve échoue donc quand la garde disparaît.
const PAS_UNE_LISTE = 8;
// Fige une valeur et, à toute profondeur, les objets et les tableaux qu'elle
// contient : une écriture du module éprouvé y lève TypeError.
function figer(valeur) {
if (typeof valeur !== 'object' || valeur === null || ArrayBuffer.isView(valeur)) return valeur;
for (const element of Object.values(valeur)) figer(element);
return Object.freeze(valeur);
}
// Participants d'identifiants donnés, sans appartenance.
const sansAppartenance = (ids) => ids.map((id) => ({ id, nom: `P${id}`, appartenance: null }));
// Tables d'identifiant et de numéro 1 à n, n le nombre de capacités.
const tablesDe = (capacites) =>
capacites.map((capacite, i) => ({ id: i + 1, numero: i + 1, capacite }));
// La petite démonstration (§ 15.3) : douze participants, quatre tables de 3,
// quatre tours, aucune réservation.
const PETITE = normaliser(CATALOGUE.find(({ cle }) => cle === 'petite').construire());
// Le plan parfait, 3 et 4 échangés au tour 1 : 1 retrouve 4 au tour 2, et 3
// retrouve 5 au tour 3. Ces quatre personnes rencontrent 7 personnes, les
// huit autres toujours 8 ; les tables restent pleines, et le plafond réalisé
// vaut 8 pour chacun.
const PLAN_DEGRADE = {
tables: [1, 2, 3, 4],
tours: [[[1, 2, 4], [3, 5, 6], [7, 8, 9], [10, 11, 12]], ...PLAN_PARFAIT_PETITE.tours.slice(1)],
reserves: [[], [], [], []],
};
// Le plan parfait, 1 et 5 échangés au tour 1. 1 y quitte 2 et 3, et 5 y
// quitte 4 et 6, qu'ils ne voient à aucun autre tour. Chacun s'assoit près de
// deux personnes qu'il revoit à un autre tour : 1 près de 4 et 6, revus aux
// tours 2 et 3 ; 5 près de 2 et 3, revus aux tours 2 et 3. 2, 3, 4 et 6
// perdent celui des deux qui quitte leur table, et revoient ailleurs celui
// qui y arrive. Les tables restent pleines, et le plafond réalisé vaut 8 pour
// chacun.
//
// id 1 2 3 4 5 6 7 à 12
// rencontres 6 7 7 7 6 7 8
// manque 2 1 1 1 2 1 0
const PLAN_DEUX_MANQUES = {
tables: [1, 2, 3, 4],
tours: [[[2, 3, 5], [1, 4, 6], [7, 8, 9], [10, 11, 12]], ...PLAN_PARFAIT_PETITE.tours.slice(1)],
reserves: [[], [], [], []],
};
// Deux tables de 3 sièges, quatre tours, six participants sans appartenance.
// 1 est ancré à la table 1 et 2 à la table 2 ; 3, réservé à la table 1 au
// tour 1, est partiellement fixé et compte parmi les mobiles (§ 5.4) ; 4, 5 et
// 6 sont mobiles. 6, assis au seul tour 1, est en réserve ensuite (§ 12.6).
const CONFIGURATION_ANCRAGES = {
participants: sansAppartenance([1, 2, 3, 4, 5, 6]),
tables: tablesDe([3, 3]),
tours: 4,
reservations: [
{ participant: 1, table: 1, portee: 'tous' },
{ participant: 2, table: 2, portee: 'tous' },
{ participant: 3, table: 1, portee: 'tour', tour: 1 },
],
contraintes: SANS_CONTRAINTE,
};
const ANCRAGES = normaliser(CONFIGURATION_ANCRAGES);
const PLAN_ANCRAGES = {
tables: [1, 2],
tours: [
[[1, 3, 4], [2, 5, 6]],
[[1, 3, 5], [2, 4]],
[[1, 4], [2, 3, 5]],
[[1, 3, 4], [2, 5]],
],
reserves: [[], [6], [6], [6]],
};
// Les termes de PLAN_ANCRAGES, comptés à la main. Occupation des tables 1 et
// 2 : 3 et 3 au tour 1, 3 et 2 au tour 2, 2 et 3 au tour 3, 3 et 2 au tour 4.
// N − 1 = 5 et n = 4 : n_p vaut 4 pour un ancré, 3 pour les autres. Plafond
// réalisé = Σ a_t sur les tables visitées + min(n_p, Σ (o − 1 − a_t) sur les
// tours assis), a_t valant 1 à chaque table, et 0 à la sienne pour un ancré.
//
// id rencontrés rencontres plafond réalisé a priori
// 1 3, 4, 5 3 0 + min(4, 2+2+1+2) = 4 4
// 2 3, 4, 5, 6 4 0 + min(4, 2+1+2+1) = 4 4
// 3 1, 2, 4, 5 4 2 + min(3, 1+1+1+1) = 5 5
// 4 1, 2, 3 3 2 + min(3, 1+0+0+1) = 4 5
// 5 1, 2, 3, 6 4 2 + min(3, 1+1+1+0) = 5 5
// 6 2, 5 2 1 + min(3, 1) = 2 5
//
// Le plafond a priori se lit sur les capacités : 0 + min(4, 4 × 2) pour un
// ancré ; les deux tables visitées, 2 + min(3, 4 × 1), pour les autres.
// Manque = réalisé − rencontres : 1, 0, 1, 1, 1, 0. Écart d'itinéraire = a
// priori − réalisé : 0, 0, 0, 1, 0, 3.
const RENCONTRES_ANCRAGES = [3, 4, 4, 3, 4, 2];
const REALISES_ANCRAGES = [4, 4, 5, 4, 5, 2];
const A_PRIORI_ANCRAGES = [4, 4, 5, 5, 5, 5];
// Quatre participants, deux tables de 3, un tour, deux à chaque table. Chacun
// rencontre la seule personne que sa table lui offre : plafond réalisé 1,
// manque 0. Une table pleine lui en offrait deux : plafond a priori 2, écart
// d'itinéraire 1.
const BAS = normaliser({
participants: sansAppartenance([1, 2, 3, 4]),
tables: tablesDe([3, 3]),
tours: 1,
reservations: [],
contraintes: SANS_CONTRAINTE,
});
const PLAN_BAS = { tables: [1, 2], tours: [[[1, 2], [3, 4]]], reserves: [[]] };
// Aucun participant ; le plan n'a aucune case.
const VIDE = normaliser({
participants: [],
tables: tablesDe([2]),
tours: 1,
reservations: [],
contraintes: SANS_CONTRAINTE,
});
const PLAN_VIDE = { tables: [1], tours: [[[]]], reserves: [[]] };
// Mesures, plafonds réalisés et plafonds a priori d'un plan par
// identifiants.
function chiffrer(instance, plan) {
const tableDe = indexerPlan(instance, plan);
return {
mesures: mesurer(instance, tableDe),
realises: plafondsRealises(instance, tableDe),
aPriori: plafondsAPriori(instance),
};
}
// Profil de manque d'un plan par identifiants.
function profilDe(instance, plan) {
const { mesures, realises } = chiffrer(instance, plan);
return profilManque(instance, manques(realises, mesures.rencontres), realises);
}
// Profil écrit à la main : le manque de chaque rang, le rang 1 d'abord, et
// l'identifiant de la personne à chaque rang, 1 à n par défaut. Le plafond
// réalisé ne départage pas : comparerProfils ne lit que le manque.
const profil = (manquesParRang, ids = manquesParRang.map((_, i) => i + 1)) =>
manquesParRang.map((manque, i) => ({ id: ids[i], manque, plafondRealise: 10 }));
describe('manques (§ 12.10.2)', () => {
test('plan parfait de la petite démonstration : douze manques nuls', () => {
const { mesures, realises } = chiffrer(PETITE, PLAN_PARFAIT_PETITE);
assert.deepEqual(mesures.rencontres, HUIT);
assert.deepEqual(realises, HUIT);
assert.deepEqual(manques(figer(realises), figer(mesures.rencontres)), new Array(12).fill(0));
});
test('plan dégradé : un manque de 1 pour les quatre personnes qui perdent une rencontre', () => {
const { mesures, realises } = chiffrer(PETITE, PLAN_DEGRADE);
assert.deepEqual(mesures.rencontres, [7, 8, 7, 7, 7, 8, 8, 8, 8, 8, 8, 8]);
assert.deepEqual(realises, HUIT);
assert.deepEqual(manques(realises, mesures.rencontres), [1, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0]);
});
test("ancrés et réserve : plafond réalisé − rencontres, personne par personne", () => {
const { mesures, realises, aPriori } = chiffrer(ANCRAGES, PLAN_ANCRAGES);
assert.deepEqual(mesures.rencontres, RENCONTRES_ANCRAGES);
assert.deepEqual(realises, REALISES_ANCRAGES);
assert.deepEqual(aPriori, A_PRIORI_ANCRAGES);
assert.deepEqual(manques(realises, mesures.rencontres), [1, 0, 1, 1, 1, 0]);
// Les tableaux typés se lisent comme des listes.
assert.deepEqual(
manques(Int32Array.from(REALISES_ANCRAGES), Int32Array.from(RENCONTRES_ANCRAGES)),
[1, 0, 1, 1, 1, 0],
);
});
test('deux listes de longueurs différentes lèvent RangeError', () => {
assert.throws(() => manques([8, 8], [8]), RangeError);
assert.throws(() => manques([8], [8, 8]), RangeError);
});
test("une valeur qui n'est pas une liste lève TypeError, à l'une ou l'autre place", () => {
assert.throws(() => manques(PAS_UNE_LISTE, [8]), TypeError);
assert.throws(() => manques([8], PAS_UNE_LISTE), TypeError);
});
});
describe('ecartsItineraire (§ 5.5)', () => {
test('plafond a priori − plafond réalisé, personne par personne', () => {
const { realises, aPriori } = chiffrer(ANCRAGES, PLAN_ANCRAGES);
// 4 − 4, 4 − 4, 5 − 5, 5 − 4, 5 − 5, 5 − 2 : 6, en réserve à trois tours,
// porte l'écart d'itinéraire que son manque nul ne porte pas (§ 12.6).
assert.deepEqual(ecartsItineraire(figer(aPriori), figer(realises)), [0, 0, 0, 1, 0, 3]);
});
test('null quand le plafond a priori est inconnu ; undefined ne passe pas pour inconnu', () => {
const { realises } = chiffrer(ANCRAGES, PLAN_ANCRAGES);
assert.equal(ecartsItineraire(null, realises), null);
assert.throws(() => ecartsItineraire(undefined, realises), TypeError);
});
test("un plafond a priori d'une autre longueur lève RangeError ; un nombre seul pour plafonds réalisés, TypeError", () => {
const { realises } = chiffrer(ANCRAGES, PLAN_ANCRAGES);
assert.throws(() => ecartsItineraire(A_PRIORI_ANCRAGES.slice(1), realises), RangeError);
assert.throws(() => ecartsItineraire(null, PAS_UNE_LISTE), TypeError);
});
});
describe('ecartsAuPlafondAPriori (§ 5.5)', () => {
test("plafond a priori − rencontres : le manque plus l'écart d'itinéraire, personne par personne", () => {
const { mesures, aPriori } = chiffrer(ANCRAGES, PLAN_ANCRAGES);
// 1 + 0, 0 + 0, 1 + 0, 1 + 1, 1 + 0, 0 + 3.
assert.deepEqual(ecartsAuPlafondAPriori(figer(mesures), figer(aPriori)), [1, 0, 1, 2, 1, 3]);
});
test('null quand le plafond a priori est inconnu ; undefined ne passe pas pour inconnu', () => {
const { mesures } = chiffrer(ANCRAGES, PLAN_ANCRAGES);
assert.equal(ecartsAuPlafondAPriori(mesures, null), null);
assert.throws(() => ecartsAuPlafondAPriori(mesures, undefined), TypeError);
});
test("un plafond a priori d'une autre longueur que les rencontres lève RangeError", () => {
const { mesures } = chiffrer(ANCRAGES, PLAN_ANCRAGES);
assert.throws(() => ecartsAuPlafondAPriori(mesures, [4, 4, 5, 5, 5]), RangeError);
});
test('des rencontres qui ne sont pas une liste lèvent TypeError, le plafond a priori connu ou inconnu', () => {
assert.throws(() => ecartsAuPlafondAPriori({ rencontres: PAS_UNE_LISTE }, A_PRIORI_ANCRAGES), TypeError);
assert.throws(() => ecartsAuPlafondAPriori({ rencontres: PAS_UNE_LISTE }, null), TypeError);
});
});
describe('troisChiffres (§ 12.10.4)', () => {
test('plan parfait de la petite démonstration : 0, un vrai 0, et 0 ; « — » pour les ancrés', () => {
const { mesures, realises, aPriori } = chiffrer(PETITE, PLAN_PARFAIT_PETITE);
// Douze personnes mesurées, aucune en manque : l'effectif du manque est un
// zéro mesuré, non un « — ». Sans réservation, la population des ancrés
// est vide, et chacun des trois chiffres s'y écrit « — » (§ 15.3).
assert.deepEqual(troisChiffres(PETITE, figer(mesures), figer(aPriori), figer(realises)), {
manqueMax: { tous: 0, mobiles: 0, ancres: null },
effectifManque: { tous: 0, mobiles: 0, ancres: null },
ecartItineraireMax: { tous: 0, mobiles: 0, ancres: null },
});
});
test('plan dégradé : le plus grand manque vaut 1, et quatre personnes le portent', () => {
const { mesures, realises, aPriori } = chiffrer(PETITE, PLAN_DEGRADE);
assert.deepEqual(troisChiffres(PETITE, mesures, aPriori, realises), {
manqueMax: { tous: 1, mobiles: 1, ancres: null },
effectifManque: { tous: 4, mobiles: 4, ancres: null },
ecartItineraireMax: { tous: 0, mobiles: 0, ancres: null },
});
});
test("un manque de 2 : l'effectif compte les personnes, non la somme des manques (§ 12.10.8)", () => {
const { mesures, realises, aPriori } = chiffrer(PETITE, PLAN_DEUX_MANQUES);
assert.deepEqual(mesures.rencontres, [6, 7, 7, 7, 6, 7, 8, 8, 8, 8, 8, 8]);
assert.deepEqual(realises, HUIT);
// Manques 2, 1, 1, 1, 2, 1, puis six 0 : six personnes en manque, pour
// une somme de 8.
assert.deepEqual(troisChiffres(PETITE, mesures, aPriori, realises), {
manqueMax: { tous: 2, mobiles: 2, ancres: null },
effectifManque: { tous: 6, mobiles: 6, ancres: null },
ecartItineraireMax: { tous: 0, mobiles: 0, ancres: null },
});
});
test('le placement du § 12.10.4 : un manque de 13, porté par une seule personne', () => {
// Chacun atteint un plafond réalisé de 27, sauf une personne qui ne
// rencontre que 14 : le plus grand manque vaut 13, l'effectif 1.
// troisChiffres lit des listes, non un plan : celles de l'exemple, écrites
// à la main sur trois personnes, le plafond a priori inconnu.
const trois = normaliser({
participants: sansAppartenance([1, 2, 3]),
tables: tablesDe([3]),
tours: 1,
reservations: [],
contraintes: SANS_CONTRAINTE,
});
assert.deepEqual(troisChiffres(trois, { rencontres: [27, 27, 14] }, null, [27, 27, 27]), {
manqueMax: { tous: 13, mobiles: 13, ancres: null },
effectifManque: { tous: 1, mobiles: 1, ancres: null },
ecartItineraireMax: null,
});
});
test('ancrés, partiellement fixé et réserve : chaque chiffre sur ses trois populations', () => {
const { mesures, realises, aPriori } = chiffrer(ANCRAGES, PLAN_ANCRAGES);
// Manque 1, 0, 1, 1, 1, 0 ; écart d'itinéraire 0, 0, 0, 1, 0, 3. Les
// ancrés sont 1 et 2 ; les mobiles, 3, partiellement fixé, puis 4, 5 et 6.
// 6, en réserve à trois tours sur quatre, a un manque nul : les deux
// premiers chiffres ne le voient pas, le troisième le montre (§ 12.10.4).
assert.deepEqual(troisChiffres(ANCRAGES, mesures, aPriori, realises), {
manqueMax: { tous: 1, mobiles: 1, ancres: 1 },
effectifManque: { tous: 4, mobiles: 3, ancres: 1 },
ecartItineraireMax: { tous: 3, mobiles: 3, ancres: 0 },
});
});
test("plafond a priori inconnu : l'écart d'itinéraire maximal vaut null, les deux autres se mesurent", () => {
const { mesures, realises } = chiffrer(ANCRAGES, PLAN_ANCRAGES);
assert.deepEqual(troisChiffres(ANCRAGES, mesures, null, realises), {
manqueMax: { tous: 1, mobiles: 1, ancres: 1 },
effectifManque: { tous: 4, mobiles: 3, ancres: 1 },
ecartItineraireMax: null,
});
});
test("itinéraires bas : aucun manque, et l'écart d'itinéraire le dit (§ 5.5)", () => {
const { mesures, realises, aPriori } = chiffrer(BAS, PLAN_BAS);
assert.deepEqual(mesures.rencontres, [1, 1, 1, 1]);
assert.deepEqual(realises, [1, 1, 1, 1]);
assert.deepEqual(aPriori, [2, 2, 2, 2]);
assert.deepEqual(troisChiffres(BAS, mesures, aPriori, realises), {
manqueMax: { tous: 0, mobiles: 0, ancres: null },
effectifManque: { tous: 0, mobiles: 0, ancres: null },
ecartItineraireMax: { tous: 1, mobiles: 1, ancres: null },
});
});
test('population vide : « — » pour chacun des trois chiffres, jamais 0 (§ 5.4)', () => {
const { mesures, realises, aPriori } = chiffrer(VIDE, PLAN_VIDE);
assert.deepEqual(aPriori, []);
assert.deepEqual(troisChiffres(VIDE, mesures, aPriori, realises), {
manqueMax: AUCUN,
effectifManque: AUCUN,
ecartItineraireMax: AUCUN,
});
});
test('une liste sans une case par participant lève RangeError, seule ou avec les deux autres', () => {
const { mesures, realises, aPriori } = chiffrer(ANCRAGES, PLAN_ANCRAGES);
const court = (liste) => liste.slice(1);
assert.throws(() => troisChiffres(ANCRAGES, mesures, aPriori, court(realises)), RangeError);
assert.throws(() => troisChiffres(ANCRAGES, mesures, court(aPriori), realises), RangeError);
assert.throws(
() => troisChiffres(ANCRAGES, { rencontres: court(mesures.rencontres) }, aPriori, realises),
RangeError,
);
// Les trois listes ensemble, mesurées sur une autre population : accordées
// entre elles, elles passent les gardes de manques et d'ecartsItineraire,
// qui les comparent l'une à l'autre ; seules celles de troisChiffres les
// confrontent à l'instance. D'abord une population plus petite d'une
// personne, puis une proposition mesurée avant l'exclusion de 6 et relue
// sur l'instance qui l'exclut (§ 9, § 12.6) ; chaque fois l'a priori
// connu, puis inconnu.
const plusPetite = { rencontres: court(mesures.rencontres) };
assert.throws(() => troisChiffres(ANCRAGES, plusPetite, court(aPriori), court(realises)), RangeError);
assert.throws(() => troisChiffres(ANCRAGES, plusPetite, null, court(realises)), RangeError);
const sansSix = normaliser({
...CONFIGURATION_ANCRAGES,
participants: CONFIGURATION_ANCRAGES.participants.map((p) => (p.id === 6 ? { ...p, exclu: true } : p)),
});
assert.deepEqual(sansSix.ids, [1, 2, 3, 4, 5]);
assert.throws(() => troisChiffres(sansSix, mesures, aPriori, realises), RangeError);
assert.throws(() => troisChiffres(sansSix, mesures, null, realises), RangeError);
});
test("une valeur qui n'est pas une liste lève TypeError ; un a priori undefined aussi", () => {
const { mesures, realises, aPriori } = chiffrer(ANCRAGES, PLAN_ANCRAGES);
assert.throws(() => troisChiffres(ANCRAGES, mesures, aPriori, PAS_UNE_LISTE), TypeError);
assert.throws(() => troisChiffres(ANCRAGES, { rencontres: PAS_UNE_LISTE }, aPriori, realises), TypeError);
assert.throws(() => troisChiffres(ANCRAGES, mesures, undefined, realises), TypeError);
});
});
describe('profilManque (§ 12.10.6)', () => {
test('manque décroissant, puis plafond réalisé croissant, puis identifiant croissant', () => {
// Identifiants 10 à 50, reçus dans le désordre ; les listes suivent
// l'ordre canonique, celui de instance.ids.
const instance = normaliser({
participants: sansAppartenance([30, 10, 50, 20, 40]),
tables: tablesDe([5]),
tours: 1,
reservations: [],
contraintes: SANS_CONTRAINTE,
});
assert.deepEqual(instance.ids, [10, 20, 30, 40, 50]);
// id 10 20 30 40 50
// manque 1 2 1 1 0
// plafond réalisé 5 3 4 5 2
assert.deepEqual(profilManque(instance, figer([1, 2, 1, 1, 0]), figer([5, 3, 4, 5, 2])), [
{ id: 20, manque: 2, plafondRealise: 3 },
{ id: 30, manque: 1, plafondRealise: 4 },
{ id: 10, manque: 1, plafondRealise: 5 },
{ id: 40, manque: 1, plafondRealise: 5 },
{ id: 50, manque: 0, plafondRealise: 2 },
]);
});
test("plan dégradé : les quatre personnes en manque d'abord, chaque palier par identifiant", () => {
const ids = profilDe(PETITE, PLAN_DEGRADE).map(({ id }) => id);
assert.deepEqual(ids, [1, 3, 4, 5, 2, 6, 7, 8, 9, 10, 11, 12]);
});
test("une liste sans une case par participant lève RangeError", () => {
assert.throws(() => profilManque(PETITE, new Array(11).fill(0), HUIT), RangeError);
assert.throws(() => profilManque(PETITE, new Array(12).fill(0), HUIT.slice(1)), RangeError);
});
});
// Les plans voisins du plan parfait de la petite démonstration : à un tour,
// deux personnes assises à deux tables différentes échangent leurs places.
// Quatre tours, six paires de tables, neuf échanges par paire : 216 plans.
function voisinsDuParfait() {
const { tours } = PLAN_PARFAIT_PETITE;
const voisins = [];
for (let r = 0; r < tours.length; r += 1) {
for (let i = 0; i < tours[r].length; i += 1) {
for (let j = i + 1; j < tours[r].length; j += 1) {
for (const x of tours[r][i]) {
for (const y of tours[r][j]) {
const tour = tours[r].map((liste, t) => {
if (t === i) return liste.map((id) => (id === x ? y : id));
if (t === j) return liste.map((id) => (id === y ? x : id));
return liste;
});
voisins.push({
nom: `${x} et ${y} échangés au tour ${r + 1}`,
plan: { ...PLAN_PARFAIT_PETITE, tours: tours.map((t, q) => (q === r ? tour : t)) },
});
}
}
}
}
}
return voisins;
}
// Deux autres plans parfaits : les tours du plan parfait dans l'ordre
// inverse, et chaque tour décalé d'une table. Les rencontres restent les
// mêmes.
const AUTRES_PARFAITS = [
{
nom: 'tours inversés',
plan: { ...PLAN_PARFAIT_PETITE, tours: [...PLAN_PARFAIT_PETITE.tours].reverse() },
},
{
nom: 'tables décalées',
plan: { ...PLAN_PARFAIT_PETITE, tours: PLAN_PARFAIT_PETITE.tours.map((t) => [...t.slice(1), t[0]]) },
},
];
// Vrai quand chacun des douze rencontre huit personnes distinctes, compté sur
// les listes du plan sans passer par le moteur. Sur un plan qui remplit les
// quatre tables à chaque tour, le plafond réalisé vaut 8 pour chacun : c'est
// exactement le cas d'un manque nul pour tous.
function chacunRencontreHuit(plan) {
const rencontres = new Map();
for (const tour of plan.tours) {
for (const liste of tour) {
for (const a of liste) {
if (!rencontres.has(a)) rencontres.set(a, new Set());
for (const b of liste) if (b !== a) rencontres.get(a).add(b);
}
}
}
return rencontres.size === 12 && [...rencontres.values()].every((vus) => vus.size === 8);
}
describe('comparerProfils : dominance de profil (§ 12.10.7)', () => {
test("petite démonstration : le plan parfait domine tout plan qui n'est pas à manque nul, et rien ne le domine", () => {
const parfait = profilDe(PETITE, PLAN_PARFAIT_PETITE);
const concurrents = [...voisinsDuParfait(), ...AUTRES_PARFAITS];
// Le test refuse de passer sans concurrent, et sans concurrent de chaque
// sorte (§ 14.2). Un échange retire à la personne déplacée deux voisins
// qu'elle ne retrouve à aucun autre tour, et l'assoit près d'une personne
// au moins qu'elle rencontre à un autre tour : elle perd deux rencontres
// et en gagne une au plus. Aucun voisin n'est donc à manque nul.
assert.equal(concurrents.length, 218);
assert.deepEqual(
concurrents.filter(({ plan }) => chacunRencontreHuit(plan)).map(({ nom }) => nom),
['tours inversés', 'tables décalées'],
);
const ecarts = concurrents.flatMap(({ nom, plan }) => {
const autre = profilDe(PETITE, plan);
const nul = chacunRencontreHuit(plan);
const lus = [
['parfait contre lui', comparerProfils(parfait, autre).domine, nul ? null : 'A'],
['lui contre parfait', comparerProfils(autre, parfait).domine, nul ? null : 'B'],
];
return lus
.filter(([, obtenu, attendu]) => obtenu !== attendu)
.map(([sens, obtenu, attendu]) => `${nom}, ${sens} : ${obtenu} au lieu de ${attendu}`);
});
assert.deepEqual(ecarts, []);
});
test('plan parfait contre plan dégradé : quatre rangs à A, huit égalités, aucune bascule', () => {
const parfait = profilDe(PETITE, PLAN_PARFAIT_PETITE);
const degrade = profilDe(PETITE, PLAN_DEGRADE);
assert.deepEqual(comparerProfils(figer(parfait), figer(degrade)), {
comparable: true,
rangsA: 4,
rangsB: 0,
egalite: 8,
rangBascule: null,
domine: 'A',
});
});
test("deux profils tout à zéro ne se dominent pas", () => {
assert.deepEqual(comparerProfils(profil([0, 0, 0]), profil([0, 0, 0], [3, 2, 1])), {
comparable: true,
rangsA: 0,
rangsB: 0,
egalite: 3,
rangBascule: null,
domine: null,
});
});
test("A domine B quand il sert au moins aussi bien à chaque rang, et mieux à l'un d'eux", () => {
// Manque de A − manque de B, rang par rang : 0, 0, −1.
assert.deepEqual(comparerProfils(profil([3, 2, 0]), profil([3, 2, 1], [2, 3, 1])), {
comparable: true,
rangsA: 1,
rangsB: 0,
egalite: 2,
rangBascule: null,
domine: 'A',
});
assert.deepEqual(comparerProfils(profil([3, 2, 1], [2, 3, 1]), profil([3, 2, 0])), {
comparable: true,
rangsA: 0,
rangsB: 1,
egalite: 2,
rangBascule: null,
domine: 'B',
});
});
});
describe('comparerProfils : rangs et bascule (§ 12.10.6)', () => {
test("un seul changement de signe : le rang de bascule, premier rang où l'autre sert mieux", () => {
// −1, −1, +1, +1, 0 : A sert mieux aux rangs 1 et 2, B aux rangs 3 et 4.
assert.deepEqual(comparerProfils(profil([5, 3, 2, 1, 0]), profil([6, 4, 1, 0, 0], [5, 4, 3, 2, 1])), {
comparable: true,
rangsA: 2,
rangsB: 2,
egalite: 1,
rangBascule: 3,
domine: null,
});
// +1, +1, −1 : B sert mieux d'abord, A à partir du rang 3.
assert.deepEqual(comparerProfils(profil([6, 4, 1]), profil([5, 3, 2])), {
comparable: true,
rangsA: 1,
rangsB: 2,
egalite: 0,
rangBascule: 3,
domine: null,
});
});
test("une égalité au croisement n'est pas un changement de signe", () => {
// −1, 0, +1, +1 : une seule bascule, au rang 3, où B sert strictement mieux.
assert.deepEqual(comparerProfils(profil([5, 3, 2, 1]), profil([6, 3, 1, 0])), {
comparable: true,
rangsA: 1,
rangsB: 2,
egalite: 1,
rangBascule: 3,
domine: null,
});
// 0, 0, −1, +1 : les égalités de tête ne fixent aucun signe ; bascule au rang 4.
assert.deepEqual(comparerProfils(profil([4, 3, 1, 1]), profil([4, 3, 2, 0])), {
comparable: true,
rangsA: 1,
rangsB: 1,
egalite: 2,
rangBascule: 4,
domine: null,
});
});
test('deux changements de signe ou plus : aucun rang de bascule (§ 12.6)', () => {
// −1, +1, −1, 0.
assert.deepEqual(comparerProfils(profil([4, 3, 1, 0]), profil([5, 2, 2, 0])), {
comparable: true,
rangsA: 2,
rangsB: 1,
egalite: 1,
rangBascule: null,
domine: null,
});
// −1, +1, −1, +1.
assert.deepEqual(comparerProfils(profil([4, 3, 1, 1]), profil([5, 2, 2, 0])), {
comparable: true,
rangsA: 2,
rangsB: 2,
egalite: 0,
rangBascule: null,
domine: null,
});
});
});
describe('comparerProfils : populations (§ 12.6)', () => {
test('populations différentes : { comparable: false }', () => {
assert.deepEqual(comparerProfils(profil([1, 0, 0]), profil([1, 0, 0], [1, 2, 4])), {
comparable: false,
});
assert.deepEqual(comparerProfils(profil([1, 0, 0]), profil([1, 0])), { comparable: false });
assert.deepEqual(comparerProfils(profil([1, 0]), profil([1, 0, 0])), { comparable: false });
});
test("les mêmes personnes à d'autres rangs restent comparables, et deux profils vides aussi", () => {
assert.equal(comparerProfils(profil([1, 0, 0]), profil([1, 0, 0], [3, 1, 2])).comparable, true);
assert.deepEqual(comparerProfils([], []), {
comparable: true,
rangsA: 0,
rangsB: 0,
egalite: 0,
rangBascule: null,
domine: null,
});
});
});
describe('minimumAtteint : le certificat du § 5.5', () => {
test('vrai sur le plan parfait de la petite démonstration', () => {
const { mesures, aPriori } = chiffrer(PETITE, PLAN_PARFAIT_PETITE);
assert.deepEqual(aPriori, HUIT);
assert.equal(minimumAtteint(figer(mesures), figer(aPriori)), true);
});
test("faux après l'échange de deux personnes au tour 1, qui crée deux répétitions", () => {
const { mesures, aPriori } = chiffrer(PETITE, PLAN_DEGRADE);
assert.equal(mesures.rencontresRepetees.choisies, 2);
assert.equal(minimumAtteint(mesures, aPriori), false);
});
test('se lit contre le plafond a priori, jamais sur le seul manque', () => {
// Des itinéraires bas : chacun atteint son plafond réalisé, aucun son
// plafond a priori.
const { mesures, realises, aPriori } = chiffrer(BAS, PLAN_BAS);
assert.deepEqual(manques(realises, mesures.rencontres), [0, 0, 0, 0]);
assert.equal(minimumAtteint(mesures, aPriori), false);
});
test('égaler le plafond a priori, non le dépasser : un dépassement ne certifie rien', () => {
// 9 rencontres pour un plafond a priori de 8 rompent la ligne de
// décomposition (§ 12.10.5) : un calcul faux, que le certificat ne couvre
// pas.
assert.equal(minimumAtteint({ rencontres: [9, 8] }, [8, 8]), false);
});
test('null quand le plafond a priori est inconnu, et sur une population vide', () => {
assert.equal(minimumAtteint(chiffrer(PETITE, PLAN_PARFAIT_PETITE).mesures, null), null);
const { mesures, aPriori } = chiffrer(VIDE, PLAN_VIDE);
assert.equal(minimumAtteint(mesures, aPriori), null);
});
test("un a priori undefined lève TypeError ; d'une autre longueur, RangeError", () => {
const { mesures } = chiffrer(PETITE, PLAN_PARFAIT_PETITE);
assert.throws(() => minimumAtteint(mesures, undefined), TypeError);
assert.throws(() => minimumAtteint(mesures, HUIT.slice(1)), RangeError);
});
test('des rencontres qui ne sont pas une liste lèvent TypeError, le plafond a priori connu ou inconnu', () => {
assert.throws(() => minimumAtteint({ rencontres: PAS_UNE_LISTE }, HUIT), TypeError);
assert.throws(() => minimumAtteint({ rencontres: PAS_UNE_LISTE }, null), TypeError);
});
});

359
src/moteur/plafond.js Normal file
View file

@ -0,0 +1,359 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Plafonds de rencontres (§ 5.5). Le plafond d'une personne p sur un
// itinéraire — la table qu'elle occupe à chaque tour — vaut
//
// min(N − 1, Σ_{t∈D} a_t + min(n_p, S))
//
// D est l'ensemble des tables distinctes visitées ; a_t, les ancrés de la
// table t, p non compté ; n_p = n − 1 pour un mobile ou un partiellement
// fixé, n pour un ancré ; S, les sièges qui changent d'occupant, sommés sur
// les tours assis. Les ancrés d'une table visitée comptent une fois, quel
// que soit le nombre de tours passés là ; les sièges se renouvellent à chaque
// tour, sans livrer plus de personnes qu'il n'existe de mobiles. Un tour en
// réserve n'entre ni dans D ni dans S.
//
// La formule s'écrit une fois, dans borne, et se calcule sur deux bases :
// - les capacités, S = Σ_{t∈D} m_t · (c_t − 1 − a_t), m_t les tours passés à
// t : plafondItineraire, et son maximum sur les itinéraires admissibles, le
// plafond a priori ;
// - l'occupation, S = Σ_{(t,r)∈I} (o_{t,r} − 1 − a_t), I les couples (table,
// tour) où p est assis : le plafond réalisé. Sur un plan qui honore les
// réservations, les a_t ancrés et p occupent la table à chaque tour, et
// chaque terme est positif ou nul.
//
// Un itinéraire admissible impose les tours fixés par les réservations et
// place chaque tour libre à une table non entièrement ancrée : les ancrés
// d'une telle table occupent chacun de ses sièges à chaque tour. Quand
// aucune table ne l'est, les tours libres restent en réserve.
//
// Le plafond a priori majore le plafond réalisé de tout plan valide. Dans un
// tel plan, l'occupation ne dépasse pas la capacité : le plafond réalisé de p
// ne dépasse pas le plafond de son itinéraire. Cet itinéraire tient ses tours
// fixés et place ses tours libres à des tables admissibles, ou en réserve ;
// asseoir p à une table admissible n'ôte rien au plafond, puisque
// a_t ≥ 0 et c_t − 1 − a_t ≥ 0.
//
// Une personne se désigne par son index dans l'instance, une table par son
// index ; un tableau rendu suit l'ordre de instance.ids. Aucune fonction ne
// modifie l'instance.
//
// Une case d'itinéraire en réserve vaut RESERVE, et RESERVE vaut LIBRE
// (configuration.js) : une rangée de instance.fixe se lit comme l'itinéraire
// qui laisse en réserve chaque tour libre.
import { LIBRE, RESERVE, STATUT, decrire, exigerPlanIndexe } from './configuration.js';
import { ErreurConfiguration } from './erreurs.js';
// Nombre de multiensembles au-delà duquel l'énumération refuse une
// signature.
const LIMITE_ENUMERATION = 300_000;
// La formule du § 5.5. ancres : Σ a_t sur les tables distinctes ; sieges :
// les sièges qui changent d'occupant ; nP : n_p.
function borne(instance, nP, ancres, sieges) {
return Math.min(instance.N - 1, ancres + Math.min(nP, sieges));
}
// n_p : les mobiles — partiellement fixés compris — que p peut rencontrer,
// p excepté quand il en est un.
const mobilesRencontrables = (instance, p) =>
instance.statut[p] === STATUT.ANCRE ? instance.n : instance.n - 1;
// Table d'ancrage de p, −1 s'il n'est pas ancré.
const ancrageDe = (instance, p) =>
instance.statut[p] === STATUT.ANCRE ? instance.fixe[p * instance.R] : -1;
// a_t vu de p : les ancrés de la table t, p non compté.
const ancresVus = (instance, t, ancrage) => instance.ancresParTable[t] - (t === ancrage ? 1 : 0);
// Tables qu'un tour libre peut recevoir, par index croissant : celles où les
// ancrés laissent au moins un siège.
function tablesAdmissibles({ T, capacite, ancresParTable }) {
const admissibles = [];
for (let t = 0; t < T; t += 1) if (ancresParTable[t] < capacite[t]) admissibles.push(t);
return admissibles;
}
// Vrai quand la table de la case debut + r figure déjà à l'un des tours
// précédents de la rangée.
function dejaVisitee(cases, debut, r) {
for (let q = 0; q < r; q += 1) if (cases[debut + q] === cases[debut + r]) return true;
return false;
}
// Plafond de p sur la rangée cases[debut … debut + R − 1], une table par
// tour. occupation porte o_{t,r} en [t × R + r] et fait la base du plafond
// réalisé ; null fait celle des capacités.
function plafondSurRangee(instance, p, cases, debut, occupation) {
const { R, capacite } = instance;
const ancrage = ancrageDe(instance, p);
let ancres = 0;
let sieges = 0;
for (let r = 0; r < R; r += 1) {
const t = cases[debut + r];
if (t === RESERVE) continue;
const a = ancresVus(instance, t, ancrage);
const occupants = occupation === null ? capacite[t] : occupation[t * R + r];
sieges += occupants - 1 - a;
if (!dejaVisitee(cases, debut, r)) ancres += a;
}
return borne(instance, mobilesRencontrables(instance, p), ancres, sieges);
}
// Lève RangeError quand une case de l'itinéraire n'est ni RESERVE ni un index
// de table. Un plan indexé passe par exigerPlanIndexe (configuration.js).
function exigerCases(itineraire, T) {
for (let i = 0; i < itineraire.length; i += 1) {
const t = itineraire[i];
if (t !== RESERVE && !(Number.isInteger(t) && t >= 0 && t < T)) {
throw new RangeError(`itinéraire, case ${i} : index de table ${decrire(t)} hors de −1..${T - 1}`);
}
}
}
/**
* Plafond d'une personne sur un itinéraire (index de table par tour, −1 en
* réserve), calculé sur les capacités :
* min(N − 1, Σ_{t∈D} a_t + min(n_p, Σ_{t∈D} m_t·v_t)), v_t = c_t − 1 − a_t,
* a_t excluant p ; n_p = n − 1 pour un mobile ou un partiel, n pour un ancré.
*
* L'itinéraire n'est pas confronté aux réservations : la formule vaut pour
* tout itinéraire, et le plafond a priori en prend le maximum sur les
* itinéraires admissibles. Lève RangeError quand p n'est pas un index de
* personne, ou que l'itinéraire n'a pas R cases, chacune −1 ou un index de
* table.
*
* @param {import('./types.js').Instance} instance
* @param {number} p index de la personne
* @param {ArrayLike<number>} itineraire
* @returns {number}
*/
export function plafondItineraire(instance, p, itineraire) {
const { N, T, R } = instance;
if (!Number.isInteger(p) || p < 0 || p >= N) {
throw new RangeError(`personne d'index ${String(p)} hors de 0..${N - 1}`);
}
if (itineraire.length !== R) {
throw new RangeError(`itinéraire de ${itineraire.length} tours, ${R} attendus`);
}
exigerCases(itineraire, T);
return plafondSurRangee(instance, p, itineraire, 0, null);
}
/**
* Plafond réalisé de chacun, sur l'occupation : v_{t,r} = o_{t,r} − 1 − a_t,
* somme sur les tours assis ; Σ a_t reste sur les tables distinctes. Un tour
* en réserve n'entre pas dans le plafond de la personne, ni dans
* l'occupation d'aucune table.
*
* Lève ce que lève exigerPlanIndexe (configuration.js).
*
* @param {import('./types.js').Instance} instance
* @param {ArrayLike<number>} tableDe plan indexé, [p × R + r]
* @returns {number[]}
*/
export function plafondsRealises(instance, tableDe) {
exigerPlanIndexe(instance, tableDe);
const { N, T, R } = instance;
const occupation = new Int32Array(T * R);
for (let p = 0; p < N; p += 1) {
for (let r = 0; r < R; r += 1) {
const t = tableDe[p * R + r];
if (t !== RESERVE) occupation[t * R + r] += 1;
}
}
const plafonds = [];
for (let p = 0; p < N; p += 1) {
plafonds.push(plafondSurRangee(instance, p, tableDe, p * R, occupation));
}
return plafonds;
}
// Clé de signature de p : statut, table d'ancrage, multiensemble trié des
// tables fixées. Le plafond a priori ne dépend que d'elle : la formule ne lit
// ni l'ordre des tours ni l'identité de p, et les tables admissibles sont les
// mêmes pour tous.
function signature(instance, p) {
const { R, fixe, statut } = instance;
const fixees = [];
for (let r = 0; r < R; r += 1) if (fixe[p * R + r] !== LIBRE) fixees.push(fixe[p * R + r]);
fixees.sort((x, y) => x - y);
return `${statut[p]}|${ancrageDe(instance, p)}|${fixees.join(',')}`;
}
// Plafond a priori de p, par programmation dynamique. Les tours fixés donnent
// F, ses tables distinctes D₀, ancres0 = Σ_{t∈D₀} a_t et sieges0 =
// Σ_{t∈F} v_t ; restent f tours libres. Un état (s, w) — s tours libres
// attribués, w = min(cap, sièges ajoutés), cap = max(0, n_p − sieges0) —
// porte la plus grande somme d'ancrés ajoutée qui l'atteint, −1 s'il est
// inatteignable. Chaque table admissible, prise une fois, reçoit une
// multiplicité m de 0 à f − s : ses ancrés s'ajoutent si m > 0 et qu'elle
// n'est pas dans D₀, ses sièges m fois. Seuls les états à s = f concluent.
// Plafonner w à cap ne perd rien, la formule bornant les sièges à n_p ; et
// la plus grande somme d'ancrés suffit par état, la formule croissant avec
// elle.
function maximiser(instance, p, admissibles) {
const { T, R, fixe, capacite } = instance;
const ancrage = ancrageDe(instance, p);
const nP = mobilesRencontrables(instance, p);
const sieges = (t) => capacite[t] - 1 - ancresVus(instance, t, ancrage);
const dansD0 = new Uint8Array(T);
let ancres0 = 0;
let sieges0 = 0;
let f = 0;
for (let r = 0; r < R; r += 1) {
const t = fixe[p * R + r];
if (t === LIBRE) {
f += 1;
continue;
}
sieges0 += sieges(t);
if (dansD0[t] === 0) {
dansD0[t] = 1;
ancres0 += ancresVus(instance, t, ancrage);
}
}
// Sans tour libre, l'itinéraire est imposé ; sans table admissible, les
// tours libres restent en réserve.
if (f === 0 || admissibles.length === 0) return borne(instance, nP, ancres0, sieges0);
// Les sièges ajoutés ne dépassent jamais f fois le plus grand v_t : au-delà
// de cette valeur, w n'a pas de case à remplir.
let vMax = 0;
for (const t of admissibles) vMax = Math.max(vMax, sieges(t));
const W = Math.min(Math.max(0, nP - sieges0), f * vMax);
const largeur = W + 1;
let meilleur = new Int32Array((f + 1) * largeur).fill(-1);
meilleur[0] = 0;
for (const t of admissibles) {
const gain = dansD0[t] === 1 ? 0 : ancresVus(instance, t, ancrage);
const v = sieges(t);
const suivant = meilleur.slice();
for (let s = 0; s < f; s += 1) {
for (let w = 0; w <= W; w += 1) {
const atteint = meilleur[s * largeur + w];
if (atteint < 0) continue;
for (let m = 1; s + m <= f; m += 1) {
const cible = (s + m) * largeur + Math.min(W, w + m * v);
if (atteint + gain > suivant[cible]) suivant[cible] = atteint + gain;
}
}
}
meilleur = suivant;
}
let plafond = -1;
for (let w = 0; w <= W; w += 1) {
const ajout = meilleur[f * largeur + w];
if (ajout >= 0) plafond = Math.max(plafond, borne(instance, nP, ancres0 + ajout, sieges0 + w));
}
return plafond;
}
/**
* Plafond a priori de chacun, maximisé sur les itinéraires admissibles :
* tours fixés imposés, tours libres sur toute table non entièrement ancrée.
* Exact, par programmation dynamique ; mémoïsé par signature.
*
* @param {import('./types.js').Instance} instance
* @returns {number[]}
*/
export function plafondsAPriori(instance) {
const admissibles = tablesAdmissibles(instance);
const parSignature = new Map();
const plafonds = [];
for (let p = 0; p < instance.N; p += 1) {
const cle = signature(instance, p);
let plafond = parSignature.get(cle);
if (plafond === undefined) {
plafond = maximiser(instance, p, admissibles);
parSignature.set(cle, plafond);
}
plafonds.push(plafond);
}
return plafonds;
}
// Vrai quand C(A + f − 1, f), le nombre de multiensembles de f tables parmi
// A, dépasse limite. Le produit avance par C(A − 1 + i, i), entier et
// croissant en i : il s'arrête dès qu'il dépasse, avant d'excéder les
// entiers qu'un nombre représente exactement.
function multiensemblesAuDela(A, f, limite) {
let compte = 1;
for (let i = 1; i <= f; i += 1) {
compte = (compte * (A - 1 + i)) / i;
if (compte > limite) return true;
}
return false;
}
// Plafond a priori de p par énumération : chaque multiensemble de tables
// admissibles, de la taille du nombre de tours libres, se pose sur ces tours
// dans l'ordre, et plafondItineraire en donne la valeur. La formule ne lisant
// pas l'ordre des tours, un multiensemble suffit pour chaque ensemble
// d'itinéraires qui ne diffèrent que par cet ordre.
function enumerer(instance, p, admissibles) {
const { R, fixe, ids } = instance;
const itineraire = Int32Array.from(fixe.subarray(p * R, (p + 1) * R));
const libres = [];
for (let r = 0; r < R; r += 1) if (itineraire[r] === LIBRE) libres.push(r);
const f = libres.length;
const A = admissibles.length;
// Sans table admissible, les tours libres restent à −1, en réserve.
if (f === 0 || A === 0) return plafondItineraire(instance, p, itineraire);
if (multiensemblesAuDela(A, f, LIMITE_ENUMERATION)) {
throw new ErreurConfiguration('ENUMERATION_TROP_GRANDE', {
participant: ids[p],
tablesAdmissibles: A,
toursLibres: f,
limite: LIMITE_ENUMERATION,
});
}
// choix[i] : rang, dans admissibles, de la table du i-ème tour libre. Les
// suites non décroissantes se suivent dans l'ordre lexicographique, de
// (0, …, 0) à (A − 1, …, A − 1).
const choix = new Int32Array(f);
let plafond = -1;
for (;;) {
for (let i = 0; i < f; i += 1) itineraire[libres[i]] = admissibles[choix[i]];
plafond = Math.max(plafond, plafondItineraire(instance, p, itineraire));
let i = f - 1;
while (i >= 0 && choix[i] === A - 1) i -= 1;
if (i < 0) return plafond;
choix[i] += 1;
for (let j = i + 1; j < f; j += 1) choix[j] = choix[i];
}
}
/**
* Oracle : même résultat par énumération des multiensembles de tables des
* tours libres. Lève ErreurConfiguration('ENUMERATION_TROP_GRANDE') au-delà de
* 300 000 multiensembles pour une signature, avec pour détails le
* participant, par identifiant, le premier dans l'ordre des index dont
* l'énumération dépasse, le nombre de tables admissibles, celui de ses tours
* libres, et la limite.
*
* L'énumération ne partage avec plafondsAPriori que la formule et la règle
* d'admissibilité. Elle regroupe les personnes par rangée de tours fixés,
* plus fine que la signature : si la signature confondait deux personnes de
* plafonds distincts, les deux fonctions divergeraient sur l'une d'elles.
*
* @param {import('./types.js').Instance} instance
* @returns {number[]}
*/
export function plafondsAPrioriParEnumeration(instance) {
const { N, R, fixe } = instance;
const admissibles = tablesAdmissibles(instance);
const parRangee = new Map();
const plafonds = [];
for (let p = 0; p < N; p += 1) {
const cle = fixe.subarray(p * R, (p + 1) * R).join(',');
let plafond = parRangee.get(cle);
if (plafond === undefined) {
plafond = enumerer(instance, p, admissibles);
parRangee.set(cle, plafond);
}
plafonds.push(plafond);
}
return plafonds;
}

View file

@ -0,0 +1,199 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves lourdes des plafonds (§ 14.4, § 14.12) : l'accord de la
// programmation dynamique et de l'énumération sur une grille plus large que
// celle de la série node, jusqu'à 8 tables et 5 tours ; la forme de la grande
// démonstration et sa variante, arbitrées par l'énumération ; la limite de
// l'énumération à sa frontière exacte.
import assert from 'node:assert/strict';
import fc from 'fast-check';
import { describe, test } from '../../test/lanceur.js';
import { LIBRE, STATUT, normaliser } from './configuration.js';
import { ErreurConfiguration } from './erreurs.js';
import { plafondsAPriori, plafondsAPrioriParEnumeration } from './plafond.js';
const SANS_CONTRAINTE = {
separerAppartenances: false,
nouveauxVoisins: false,
nouvelleTable: false,
varierAppartenances: false,
};
// Graine de la grille, écrite ici (§ 14.12) ; chaque case de la grille en
// dérive la sienne.
const GRAINE = 314_159;
/**
* Instance d'une salle : capacites[t] et ancres[t] décrivent la table
* d'index t ; partiels[j][r] est la table imposée au j-ème partiellement
* fixé au tour r, LIBRE pour un tour libre. Identifiants 1 à N : les ancrés,
* table par table, puis les partiels, puis les mobiles ; la table d'index t
* a pour identifiant 100 + t. Lève ce que normaliser lève.
*/
function instanceDe({ capacites, ancres, mobiles, tours, partiels = [] }) {
const participants = [];
const reservations = [];
const nouveau = () => {
const id = participants.length + 1;
participants.push({ id, nom: `P${id}`, appartenance: null });
return id;
};
ancres.forEach((a, t) => {
for (let i = 0; i < a; i += 1) {
reservations.push({ participant: nouveau(), table: 100 + t, portee: 'tous' });
}
});
for (const tables of partiels) {
const id = nouveau();
tables.forEach((t, r) => {
if (t === LIBRE) return;
reservations.push({ participant: id, table: 100 + t, portee: 'tour', tour: r + 1 });
});
}
for (let i = 0; i < mobiles; i += 1) nouveau();
return normaliser({
participants,
tables: capacites.map((capacite, t) => ({ id: 100 + t, numero: t + 1, capacite })),
tours,
reservations,
contraintes: SANS_CONTRAINTE,
});
}
// Une case de la grille : T tables et R tours fixés, capacités de 2 à 6.
// Une table est entièrement ancrée une fois sur quatre, et garde sinon de 1 à
// c sièges libres. Chaque tour d'un partiel est libre une fois sur deux, ou
// désigne par son rang l'une des tables qui gardent un siège, rang pris
// modulo leur nombre : seul l'empilement de partiels à une même table, au
// même tour, fait encore refuser une instance.
function arbitraireCase(T, R) {
const table = fc
.integer({ min: 2, max: 6 })
.chain((c) =>
fc.tuple(
fc.constant(c),
fc.oneof(
{ arbitrary: fc.integer({ min: 0, max: c - 1 }), weight: 3 },
{ arbitrary: fc.constant(c), weight: 1 },
),
),
);
const tour = fc.oneof(fc.constant(LIBRE), fc.integer({ min: 0, max: T - 1 }));
return fc
.record({
salle: fc.array(table, { minLength: T, maxLength: T }),
mobiles: fc.integer({ min: 0, max: 10 }),
rangs: fc.array(fc.array(tour, { minLength: R, maxLength: R }), { maxLength: 4 }),
})
.map(({ salle, mobiles, rangs }) => {
const ouvertes = [];
salle.forEach(([c, a], t) => {
if (a < c) ouvertes.push(t);
});
const partiels = rangs.map((tours) =>
tours.map((rang) => (rang === LIBRE || ouvertes.length === 0 ? LIBRE : ouvertes[rang % ouvertes.length])),
);
return {
capacites: salle.map(([c]) => c),
ancres: salle.map(([, a]) => a),
mobiles,
tours: R,
partiels,
};
});
}
describe("plafond a priori : la programmation dynamique contre l'énumération, grille large", () => {
test('de 2 à 8 tables et de 2 à 5 tours, chaque case de la grille : le même plafond pour chacun', () => {
const parCase = [];
// Une valeur de STATUT et son compte ; la Map ne sert qu'à retrouver un
// statut.
const parStatut = new Map();
for (let T = 2; T <= 8; T += 1) {
for (let R = 2; R <= 5; R += 1) {
let acceptees = 0;
fc.assert(
fc.property(arbitraireCase(T, R), (description) => {
let instance;
try {
instance = instanceDe(description);
} catch (erreur) {
if (erreur instanceof ErreurConfiguration && erreur.code === 'SURRESERVATION') return;
throw erreur;
}
if (instance.N === 0) return;
acceptees += 1;
for (const statut of instance.statut) parStatut.set(statut, (parStatut.get(statut) ?? 0) + 1);
assert.deepEqual(plafondsAPriori(instance), plafondsAPrioriParEnumeration(instance));
}),
{ seed: GRAINE + 10 * T + R, numRuns: 300 },
);
parCase.push([T, R, acceptees]);
}
}
// Chaque case compte ses instances acceptées : une case que le
// générateur viderait n'éprouverait rien.
assert.equal(parCase.length, 28);
const maigres = parCase
.filter(([, , acceptees]) => acceptees < 200)
.map(([T, R, n]) => `${T} tables, ${R} tours : ${n} acceptées sur 300, 200 au moins`);
assert.deepEqual(maigres, []);
const partiels = parStatut.get(STATUT.PARTIELLEMENT_FIXE) ?? 0;
const ancres = parStatut.get(STATUT.ANCRE) ?? 0;
assert.ok(partiels >= 5000, `${partiels} partiellement fixés comparés, 5000 au moins`);
assert.ok(ancres >= 5000, `${ancres} ancrés comparés, 5000 au moins`);
});
test("forme de la grande démonstration et sa variante : la programmation dynamique égale l'énumération", () => {
// Principale : 29 tables de 8 puis 4 de 7, un ancré par table. Variante :
// 33 tables de 8, 4 places libres. 227 mobiles, 4 tours ; un mobile
// énumère C(36, 4) = 58 905 multiensembles.
const principale = instanceDe({
capacites: [...Array(29).fill(8), ...Array(4).fill(7)],
ancres: Array(33).fill(1),
mobiles: 227,
tours: 4,
});
// Les ancrés portent les identifiants 1 à 33, table par table.
const attendu = new Array(260).fill(28);
for (let t = 29; t < 33; t += 1) attendu[t] = 24;
assert.deepEqual(plafondsAPriori(principale), attendu);
assert.deepEqual(plafondsAPrioriParEnumeration(principale), attendu);
const variante = instanceDe({
capacites: Array(33).fill(8),
ancres: Array(33).fill(1),
mobiles: 227,
tours: 4,
});
const vingtHuit = new Array(260).fill(28);
assert.deepEqual(plafondsAPriori(variante), vingtHuit);
assert.deepEqual(plafondsAPrioriParEnumeration(variante), vingtHuit);
});
test("la limite de l'énumération : 300 000 multiensembles passent, un de plus est refusé", () => {
// Un tour libre : le compte est celui des tables admissibles.
const unTour = (tables) =>
instanceDe({ capacites: Array(tables).fill(2), ancres: [], mobiles: 1, tours: 1 });
assert.deepEqual(plafondsAPrioriParEnumeration(unTour(300_000)), [0]);
assert.throws(
() => plafondsAPrioriParEnumeration(unTour(300_001)),
(erreur) =>
erreur instanceof ErreurConfiguration
&& erreur.code === 'ENUMERATION_TROP_GRANDE'
&& erreur.details.tablesAdmissibles === 300_001
&& erreur.details.toursLibres === 1,
);
// Deux tours libres : C(775, 2) = 299 925 passent, là où 774² le
// refuserait ; C(776, 2) = 300 700 est refusé.
const deuxTours = (tables) =>
instanceDe({ capacites: Array(tables).fill(2), ancres: [], mobiles: 2, tours: 2 });
const passe = deuxTours(774);
assert.deepEqual(plafondsAPrioriParEnumeration(passe), plafondsAPriori(passe));
assert.throws(
() => plafondsAPrioriParEnumeration(deuxTours(775)),
(erreur) => erreur instanceof ErreurConfiguration && erreur.code === 'ENUMERATION_TROP_GRANDE',
);
});
});

537
src/moteur/plafond.test.js Normal file
View file

@ -0,0 +1,537 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des plafonds (§ 5.5) : la formule sur un itinéraire, le plafond
// réalisé sur l'occupation, le plafond a priori maximisé sur les itinéraires
// admissibles, et l'accord de la programmation dynamique avec l'énumération
// exhaustive qui l'arbitre (§ 14.11, § 14.12). Les valeurs attendues des
// formes nommées sont celles du § 5.5, du § 14.10 et du § 15 ; les autres se
// calculent à la main, table par table, dans le commentaire qui les précède.
import assert from 'node:assert/strict';
import fc from 'fast-check';
import { describe, test } from '../../test/lanceur.js';
import {
LIBRE,
RESERVE,
STATUT,
indexerPlan,
normaliser,
planDepuisIndex,
} from './configuration.js';
import { ErreurConfiguration } from './erreurs.js';
import {
plafondItineraire,
plafondsAPriori,
plafondsAPrioriParEnumeration,
plafondsRealises,
} from './plafond.js';
const SANS_CONTRAINTE = {
separerAppartenances: false,
nouveauxVoisins: false,
nouvelleTable: false,
varierAppartenances: false,
};
// Graine de la propriété, écrite ici pour que chaque exécution tire les
// mêmes instances (§ 14.12).
const GRAINE = 271_828;
// Entiers de debut à fin, inclus.
const suite = (debut, fin) => Array.from({ length: fin - debut + 1 }, (_, i) => debut + i);
/**
* Instance d'une forme de salle. capacites[t] et ancres[t] décrivent la
* table d'index t ; autres compte les participants non ancrés, dont les
* premiers sont partiellement fixés selon partiels : partiels[j][r] est
* l'index de table imposé au tour r, LIBRE pour un tour libre. Les ancrés
* prennent les identifiants pairs tant qu'il reste des non-ancrés, et les
* identifiants de table décroissent quand l'index croît : un index lu comme
* un identifiant désigne une autre personne ou une autre table. Rend
* l'instance et les index de personne de chaque rôle ; lève ce que
* normaliser lève.
*/
function forme({ capacites, ancres, autres, tours, partiels = [] }) {
const T = capacites.length;
const idTable = (t) => 10 * (T - t);
const K = ancres.reduce((somme, a) => somme + a, 0);
const idsAncres = [];
const idsAutres = [];
for (const id of suite(1, K + autres)) {
const resteAutres = autres - idsAutres.length;
if (idsAncres.length < K && (id % 2 === 0 || resteAutres === 0)) idsAncres.push(id);
else idsAutres.push(id);
}
const reservations = [];
const ancresParTable = [];
let premier = 0;
for (let t = 0; t < T; t += 1) {
const ids = idsAncres.slice(premier, premier + ancres[t]);
premier += ancres[t];
ancresParTable.push(ids);
for (const id of ids) reservations.push({ participant: id, table: idTable(t), portee: 'tous' });
}
partiels.forEach((tables, j) => {
tables.forEach((t, r) => {
if (t === LIBRE) return;
reservations.push({
participant: idsAutres[j],
table: idTable(t),
portee: 'tour',
tour: r + 1,
});
});
});
const instance = normaliser({
participants: suite(1, K + autres).map((id) => ({ id, nom: `P${id}`, appartenance: null })),
tables: capacites.map((capacite, t) => ({ id: idTable(t), numero: t + 1, capacite })),
tours,
reservations,
contraintes: SANS_CONTRAINTE,
});
const index = (id) => instance.indexDe.get(id);
return {
instance,
ancres: ancresParTable.map((ids) => ids.map(index)),
partiels: idsAutres.slice(0, partiels.length).map(index),
mobiles: idsAutres.slice(partiels.length).map(index),
};
}
// Forme de la grande démonstration (§ 15.1) : 29 tables de 8 puis 4 de 7,
// un ancré par table, 227 non-ancrés, 4 tours. Les tables de 7 portent les
// index 29 à 32.
const grandeForme = (partiels = []) =>
forme({
capacites: [...Array(29).fill(8), ...Array(4).fill(7)],
ancres: Array(33).fill(1),
autres: 227,
tours: 4,
partiels,
});
// Plan indexé d'un plan écrit table par table : listes[r][t] porte les
// identifiants assis à la table d'index t au tour r, reserves[r] ceux du
// tour r en réserve.
const planIndexe = (instance, listes, reserves = listes.map(() => [])) =>
indexerPlan(instance, { tables: [...instance.idsTables], tours: listes, reserves });
// Plan valide quelconque, que la graine brasse : à chaque tour, chaque
// personne fixée s'assied à sa table ; les autres, dans un ordre brassé,
// prennent tour à tour un siège à la table suivante d'un cycle brassé des
// tables qui en ont encore ; sans siège, une personne va en réserve.
function planBrasse(instance, graine) {
const { N, T, R, fixe, capacite } = instance;
const tableDe = new Int32Array(N * R).fill(RESERVE);
const brassage = (i, r) => ((i + 1) * 2_654_435_761 + (r + 1) * 40_503 + graine * 97) % 1_000_003;
for (let r = 0; r < R; r += 1) {
const libres = Int32Array.from(capacite);
const aPlacer = [];
for (let p = 0; p < N; p += 1) {
const t = fixe[p * R + r];
if (t !== LIBRE) {
tableDe[p * R + r] = t;
libres[t] -= 1;
} else {
aPlacer.push(p);
}
}
aPlacer.sort((x, y) => brassage(x, r) - brassage(y, r) || x - y);
const cycle = suite(0, T - 1).sort((x, y) => brassage(N + x, r) - brassage(N + y, r) || x - y);
let rang = 0;
for (const p of aPlacer) {
let essais = 0;
while (essais < T && libres[cycle[rang % T]] === 0) {
rang += 1;
essais += 1;
}
if (essais === T) break;
const t = cycle[rang % T];
tableDe[p * R + r] = t;
libres[t] -= 1;
rang += 1;
}
}
return tableDe;
}
// Ancrés d'une table de capacité c : de 0 à c − 1, ou c une fois sur
// quatre, pour une table entièrement ancrée.
const ancresDe = (c) =>
fc.oneof(
{ arbitrary: fc.integer({ min: 0, max: c - 1 }), weight: 3 },
{ arbitrary: fc.constant(c), weight: 1 },
);
// Un tour d'un non-ancré : libre (LIBRE) une fois sur deux, fixé sinon à
// l'une des T tables.
const tourDe = (T) => fc.oneof(fc.constant(LIBRE), fc.integer({ min: 0, max: T - 1 }));
// Description d'une instance tirée dans les bornes données : chaque table
// tire sa capacité puis ses ancrés, chaque partiel un tour par tour. Un
// partiel dont aucun tour n'est fixé est un mobile ; un partiel fixé à la
// même table à chaque tour, un ancré (§ 4.2).
function arbitraireForme({ tables, tours, capacites, mobiles, partiels }) {
const table = fc
.integer({ min: capacites[0], max: capacites[1] })
.chain((c) => fc.tuple(fc.constant(c), ancresDe(c)));
return fc
.tuple(
fc.integer({ min: tables[0], max: tables[1] }),
fc.integer({ min: tours[0], max: tours[1] }),
)
.chain(([T, R]) =>
fc.record({
salle: fc.array(table, { minLength: T, maxLength: T }),
tours: fc.constant(R),
mobiles: fc.integer({ min: 0, max: mobiles }),
partiels: fc.array(fc.array(tourDe(T), { minLength: R, maxLength: R }), {
maxLength: partiels,
}),
}),
);
}
// Compare les deux calculs du plafond a priori sur chaque instance tirée.
// Une instance que normaliser refuse — une table qui reçoit plus de
// personnes fixées que de sièges — ou sans participant n'est pas comptée ;
// une autre l'est, avec ses personnes par statut et ses tables pleines
// d'ancrés. Rend ces comptes ; parStatut associe une valeur de STATUT à son
// compte, et la Map ne sert qu'à retrouver un statut.
function eprouverAccord(arbitraire, parametres) {
const bilan = { acceptees: 0, parStatut: new Map(), tablesPleines: 0 };
fc.assert(
fc.property(arbitraire, ({ salle, tours, mobiles, partiels }) => {
let instance;
try {
({ instance } = forme({
capacites: salle.map(([c]) => c),
ancres: salle.map(([, a]) => a),
autres: partiels.length + mobiles,
tours,
partiels,
}));
} catch (erreur) {
if (erreur instanceof ErreurConfiguration && erreur.code === 'SURRESERVATION') return;
throw erreur;
}
if (instance.N === 0) return;
bilan.acceptees += 1;
for (const statut of instance.statut) {
bilan.parStatut.set(statut, (bilan.parStatut.get(statut) ?? 0) + 1);
}
if (salle.some(([c, a]) => a === c)) bilan.tablesPleines += 1;
assert.deepEqual(plafondsAPriori(instance), plafondsAPrioriParEnumeration(instance));
}),
parametres,
);
return bilan;
}
describe('plafonds : valeurs exactes (§ 5.5, § 15)', () => {
test('4 tables de 5, un ancré chacune, 5 tours : un ancré 16, un mobile 19', () => {
const { instance, ancres, mobiles } = forme({
capacites: [5, 5, 5, 5],
ancres: [1, 1, 1, 1],
autres: 16,
tours: 5,
});
assert.equal(instance.N, 20);
assert.equal(instance.k, 4);
assert.equal(instance.n, 16);
// L'écart entre les deux valeurs est l'objet du test. Un ancré ne
// rencontre jamais les ancrés des autres tables : ses 5 tours × 4
// sièges ne livrent que les 16 mobiles. Sans le terme min(n_p, …), il
// monterait à 19 avec le mobile.
const attendu = new Array(20).fill(19);
for (const [p] of ancres) attendu[p] = 16;
assert.deepEqual(plafondsAPriori(instance), attendu);
assert.deepEqual(plafondsAPrioriParEnumeration(instance), attendu);
assert.equal(plafondItineraire(instance, ancres[0][0], [0, 0, 0, 0, 0]), 16);
assert.equal(plafondItineraire(instance, mobiles[0], [0, 1, 2, 3, 0]), 19);
});
test('forme de la grande démonstration : mobile 28, animateur 28 à une table de 8, 24 à une table de 7', () => {
const { instance, ancres, mobiles } = grandeForme();
assert.equal(instance.N, 260);
assert.equal(instance.k, 33);
assert.equal(instance.n, 227);
const attendu = new Array(260).fill(28);
for (let t = 29; t < 33; t += 1) attendu[ancres[t][0]] = 24;
assert.deepEqual(plafondsAPriori(instance), attendu);
// Un mobile qui passe une fois par une table de 7 : 4 ancrés, 3 × 6 + 5
// sièges, 27. Par quatre tables de 8 : 28.
assert.equal(plafondItineraire(instance, mobiles[0], [0, 1, 2, 29]), 27);
assert.equal(plafondItineraire(instance, mobiles[0], [0, 1, 2, 3]), 28);
});
test('petite démonstration : 8 pour chacun, que le plan parfait atteint (§ 15.3)', () => {
const { instance } = forme({
capacites: [3, 3, 3, 3],
ancres: [0, 0, 0, 0],
autres: 12,
tours: 4,
});
const huit = new Array(12).fill(8);
assert.deepEqual(plafondsAPriori(instance), huit);
assert.deepEqual(plafondsAPrioriParEnumeration(instance), huit);
// Le plan parfait du § 15.3, la table k du tableau à l'index k − 1. Les
// tables y sont pleines à chaque tour : le réalisé égale l'a priori.
const parfait = planIndexe(instance, [
[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]],
[[6, 9, 12], [3, 8, 11], [2, 5, 10], [1, 4, 7]],
[[4, 8, 10], [2, 7, 12], [1, 6, 11], [3, 5, 9]],
[[5, 7, 11], [1, 9, 10], [3, 4, 12], [2, 6, 8]],
]);
assert.deepEqual(plafondsRealises(instance, parfait), huit);
});
test("plafond réalisé sur l'occupation : 3 tables de 4, un ancré chacune, N = 10, 2 tours", () => {
const { instance } = forme({ capacites: [4, 4, 4], ancres: [1, 1, 1], autres: 7, tours: 2 });
assert.equal(instance.n, 7);
// Ancrés 2, 4 et 6 aux tables d'index 0, 1 et 2 ; les mobiles 1 et 3
// passent les deux tours à la table d'index 0, occupée par 3.
const tableDe = planIndexe(instance, [
[[1, 2, 3], [4, 5, 7, 8], [6, 9, 10]],
[[1, 2, 3], [4, 9, 10], [5, 6, 7, 8]],
]);
// Par identifiant, l'index valant id − 1 ; N − 1 = 9, n_p = 6 pour un
// mobile et 7 pour un ancré. Un tour à une table d'occupation o apporte
// o − 1 − a_t sièges, a_t = 1 pour un mobile et 0 pour l'ancré de la
// table :
// 1, 3 table 0 deux fois, o = 3 : 1 ancré + 1 + 1 = 3
// 5, 7, 8 tables 1 puis 2, o = 4 : 2 ancrés + 2 + 2 = 6
// 9, 10 tables 2 puis 1, o = 3 : 2 ancrés + 1 + 1 = 4
// 2 sa table, o = 3 puis 3 : 2 + 2 = 4
// 4 sa table, o = 4 puis 3 : 3 + 2 = 5
// 6 sa table, o = 3 puis 4 : 2 + 3 = 5
const realises = plafondsRealises(instance, tableDe);
assert.deepEqual(realises, [3, 4, 3, 5, 6, 5, 6, 6, 4, 4]);
// A priori, sur les capacités : un mobile visite deux tables, 2 + 2 × 2 ;
// un ancré tient sa table, 2 × 3.
const aPriori = plafondsAPriori(instance);
assert.deepEqual(aPriori, new Array(10).fill(6));
const p = instance.indexDe.get(1);
assert.equal(realises[p], 3);
assert.equal(aPriori[p], 6);
// Écart d'itinéraire du mobile 1 : 3.
assert.equal(aPriori[p] - realises[p], 3);
});
test('partiellement fixé : sa table fixée entre dans F, seuls ses tours libres varient', () => {
// Un non-ancré de la grande forme, fixé au tour 1 à une table de 7.
const { instance, partiels, mobiles, ancres } = grandeForme([[29, LIBRE, LIBRE, LIBRE]]);
const [p] = partiels;
assert.equal(instance.statut[p], STATUT.PARTIELLEMENT_FIXE);
assert.equal(instance.k, 33);
const aPriori = plafondsAPriori(instance);
// La table de 7 entre dans F : 1 ancré et 5 sièges ; ses trois tours
// libres prennent trois tables de 8 : 3 ancrés et 18 sièges. 27.
assert.equal(aPriori[p], 27);
assert.equal(aPriori[mobiles[0]], 28);
assert.equal(aPriori[ancres[29][0]], 24);
// Libéré de son tour fixé, l'itinéraire atteindrait 28 : le maximum ne
// déplace pas ce tour.
assert.equal(plafondItineraire(instance, p, [0, 1, 2, 3]), 28);
assert.equal(plafondItineraire(instance, p, [29, 0, 1, 2]), 27);
assert.equal(plafondsAPrioriParEnumeration(instance)[p], 27);
});
test("une table pleine d'ancrés n'est pas admissible ; un partiellement fixé compte parmi les mobiles", () => {
// Table d'index 0 : 5 sièges, aucun ancré. Table d'index 1 : 2 sièges,
// 2 ancrés, pleine. Le partiellement fixé est fixé au tour 1 à la table
// 0 ; N = 5, k = 2, n = 3, 2 tours.
const { instance, ancres, partiels, mobiles } = forme({
capacites: [5, 2],
ancres: [0, 2],
autres: 3,
tours: 2,
partiels: [[0, LIBRE]],
});
assert.equal(instance.n, 3);
// Partiel et mobiles : seule la table 0 est admissible, 0 ancré et
// 4 + 4 sièges, bornés par n_p = n − 1 = 2. La table pleine, admise, leur
// donnerait 4 : 2 ancrés, et v = 2 − 1 − 2 = −1 ; n_p = n en donnerait 3.
// Ancrés : 1 ancré voisin, 0 siège qui change d'occupant. 1.
const attendu = new Array(5).fill(-1);
for (const p of [...partiels, ...mobiles]) attendu[p] = 2;
for (const p of ancres[1]) attendu[p] = 1;
assert.deepEqual(plafondsAPriori(instance), attendu);
assert.deepEqual(plafondsAPrioriParEnumeration(instance), attendu);
});
test('réserve : une personne en réserve à un tour a un plafond réalisé sur ses seuls tours assis', () => {
// Deux tables de 3, le participant 2 ancré à la table d'index 0 ;
// 4 mobiles, 2 tours. Le mobile 3 est en réserve au tour 2.
const { instance } = forme({ capacites: [3, 3], ancres: [1, 0], autres: 4, tours: 2 });
const tableDe = planIndexe(
instance,
[
[[1, 2, 3], [4, 5]],
[[2, 4], [1, 5]],
],
[[], [3]],
);
// N − 1 = 4, n_p = 3 pour un mobile et 4 pour l'ancré :
// 1 table 0 (3 occupants, 1 ancré) puis 1 (2) : 1 ancré + 1 + 1 = 3
// 2 sa table, occupée par 3 puis 2 : 2 + 1 = 3
// 3 table 0 au seul tour 1 : 1 ancré + 1 = 2
// 4 table 1 (2) puis 0 (2, 1 ancré) : 1 ancré + 1 + 0 = 2
// 5 table 1, occupée par 2 aux deux tours : 1 + 1 = 2
assert.deepEqual(plafondsRealises(instance, tableDe), [3, 3, 2, 2, 2]);
const p = instance.indexDe.get(3);
assert.equal(plafondItineraire(instance, p, [0, RESERVE]), 2);
// L'écart d'itinéraire porte ce que le manque ne porte pas : 4 − 2.
assert.deepEqual(plafondsAPriori(instance), [4, 4, 4, 4, 4]);
});
test('sans table admissible, les tours libres restent en réserve', () => {
// Deux tables entièrement ancrées et un mobile, qui ne peut s'asseoir
// nulle part. Ancrés de la table de 2 : 1 voisin, 0 siège. Ancrés de la
// table de 3 : 2 voisins, 0 siège. Le mobile : 0.
const { instance, ancres, mobiles } = forme({
capacites: [2, 3],
ancres: [2, 3],
autres: 1,
tours: 2,
});
const attendu = new Array(6).fill(-1);
for (const p of ancres[0]) attendu[p] = 1;
for (const p of ancres[1]) attendu[p] = 2;
attendu[mobiles[0]] = 0;
assert.deepEqual(plafondsAPriori(instance), attendu);
assert.deepEqual(plafondsAPrioriParEnumeration(instance), attendu);
assert.equal(plafondItineraire(instance, mobiles[0], [RESERVE, RESERVE]), 0);
});
test('plafond a priori ≥ plafond réalisé pour chacun, sur trois plans quelconques de chaque forme', () => {
const formes = [
forme({ capacites: [5, 5, 5, 5], ancres: [1, 1, 1, 1], autres: 16, tours: 5 }),
grandeForme(),
grandeForme([[29, LIBRE, LIBRE, LIBRE], [LIBRE, 30, 30, LIBRE]]),
forme({ capacites: [3, 3, 3, 3], ancres: [0, 0, 0, 0], autres: 12, tours: 4 }),
forme({ capacites: [4, 4, 4], ancres: [1, 1, 1], autres: 7, tours: 2 }),
forme({ capacites: [5, 2], ancres: [0, 2], autres: 3, tours: 2, partiels: [[0, LIBRE]] }),
// Une place de moins que de participants : chaque plan met quelqu'un
// en réserve.
forme({ capacites: [3, 3], ancres: [1, 0], autres: 6, tours: 3, partiels: [[LIBRE, 1, LIBRE]] }),
];
const ecarts = [];
let comparaisons = 0;
for (let rang = 0; rang < formes.length; rang += 1) {
const { instance } = formes[rang];
const aPriori = plafondsAPriori(instance);
for (const graine of [1, 2, 3]) {
const realises = plafondsRealises(instance, planBrasse(instance, graine));
for (let p = 0; p < instance.N; p += 1) {
comparaisons += 1;
if (realises[p] > aPriori[p]) {
ecarts.push(
`forme ${rang}, graine ${graine}, index ${p} : `
+ `réalisé ${realises[p]} > a priori ${aPriori[p]}`,
);
}
}
}
}
assert.equal(comparaisons, 3 * formes.reduce((somme, { instance }) => somme + instance.N, 0));
assert.ok(comparaisons > 0, 'aucune comparaison');
assert.deepEqual(ecarts, []);
});
test("un itinéraire ou un plan indexé hors de la forme de l'instance lève RangeError", () => {
const { instance } = forme({ capacites: [3, 3], ancres: [0, 0], autres: 4, tours: 2 });
const cas = [
['itinéraire de 1 tour sur 2', () => plafondItineraire(instance, 0, [0])],
["table d'index 2 sur 2 tables", () => plafondItineraire(instance, 0, [0, 2])],
["table d'index −2", () => plafondItineraire(instance, 0, [-2, 0])],
["table d'index non entier", () => plafondItineraire(instance, 0, [0, 0.5])],
["personne d'index 4 sur 4", () => plafondItineraire(instance, 4, [0, 0])],
["personne d'index −1", () => plafondItineraire(instance, -1, [0, 0])],
['plan de 7 cases sur 8', () => plafondsRealises(instance, new Int32Array(7))],
['plan à une case −2', () => plafondsRealises(instance, Int32Array.of(0, 0, 0, 0, 1, 1, 1, -2))],
['plan à une case 2', () => plafondsRealises(instance, Int32Array.of(0, 0, 0, 0, 1, 1, 1, 2))],
];
const ecarts = cas
.map(([libelle, fonction]) => {
try {
fonction();
} catch (erreur) {
if (erreur instanceof RangeError) return null;
return `${libelle} : ${erreur?.name} au lieu de RangeError`;
}
return `${libelle} : aucun refus`;
})
.filter((ecart) => ecart !== null);
assert.deepEqual(ecarts, []);
});
test('un plan indexé hors forme : plafondsRealises nomme le participant, le tour et la valeur, comme planDepuisIndex', () => {
// Quatre participants d'identifiants 1 à 4, deux tables, deux tours. La
// dernière case est celle de l'identifiant 4 au tour 2. La chaîne "-1"
// n'est pas la réserve : le message la cite entre guillemets, pour
// qu'elle ne se lise pas comme la valeur qu'elle contient.
const { instance } = forme({ capacites: [3, 3], ancres: [0, 0], autres: 4, tours: 2 });
const fautif = [0, 0, 0, 0, 1, 1, 1, '-1'];
const attendu = {
name: 'RangeError',
message: 'plan indexé : participant 4, tour 2, index de table "-1" hors de −1..1',
};
assert.throws(() => planDepuisIndex(instance, fautif), attendu);
assert.throws(() => plafondsRealises(instance, fautif), attendu);
});
test('un itinéraire hors forme cite la valeur comme la garde du plan indexé : la chaîne "-1" entre guillemets', () => {
const { instance } = forme({ capacites: [3, 3], ancres: [0, 0], autres: 4, tours: 2 });
assert.throws(() => plafondItineraire(instance, 0, [0, '-1']), {
name: 'RangeError',
message: 'itinéraire, case 1 : index de table "-1" hors de −1..1',
});
});
});
describe("plafond a priori : la programmation dynamique contre l'énumération (§ 14.11, § 14.12)", () => {
test("au-delà de 300 000 multiensembles, l'énumération refuse ; la programmation dynamique répond", () => {
// 775 tables admissibles et 2 tours libres : C(776, 2) = 300 700
// multiensembles pour la signature des mobiles. Le premier d'entre eux
// dans l'ordre des index est nommé.
const { instance, mobiles } = forme({
capacites: Array(775).fill(2),
ancres: Array(775).fill(0),
autres: 2,
tours: 2,
});
assert.throws(
() => plafondsAPrioriParEnumeration(instance),
(erreur) => {
assert.ok(erreur instanceof ErreurConfiguration, `${erreur?.name} : ${erreur?.message}`);
assert.equal(erreur.code, 'ENUMERATION_TROP_GRANDE');
assert.deepEqual(erreur.details, {
participant: instance.ids[mobiles[0]],
tablesAdmissibles: 775,
toursLibres: 2,
limite: 300_000,
});
return true;
},
);
// N − 1 = 1 borne les deux mobiles.
assert.deepEqual(plafondsAPriori(instance), [1, 1]);
});
test('2 à 5 tables, 2 à 4 tours, capacités 2 à 5, ancrés et partiels : le même plafond pour chacun', () => {
const bilan = eprouverAccord(
arbitraireForme({ tables: [2, 5], tours: [2, 4], capacites: [2, 5], mobiles: 6, partiels: 3 }),
{ seed: GRAINE, numRuns: 500 },
);
// Une instance que normaliser refuse n'éprouve rien : la boucle compte
// les acceptées, et les personnes comparées de chaque statut.
const { acceptees, parStatut, tablesPleines } = bilan;
assert.ok(acceptees >= 200, `${acceptees} instances acceptées, 200 au moins`);
const partiels = parStatut.get(STATUT.PARTIELLEMENT_FIXE) ?? 0;
const ancres = parStatut.get(STATUT.ANCRE) ?? 0;
assert.ok(partiels >= 100, `${partiels} partiellement fixés comparés, 100 au moins`);
assert.ok(ancres >= 100, `${ancres} ancrés comparés, 100 au moins`);
assert.ok(tablesPleines >= 50, `${tablesPleines} instances à table pleine d'ancrés, 50 au moins`);
});
});

768
src/moteur/recherche.js Normal file
View file

@ -0,0 +1,768 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Recherche reproductible (§ 5.7, § 5.10). Une génération rend plusieurs
// propositions ; chacune sort d'une descente par acceptation tardive (late
// acceptance hill climbing) sur le plan complet, les N participants et les R
// tours, ancrés compris : l'appartenance d'un ancré pèse sur ses voisins de
// table comme celle de tout autre (§ 5.2).
//
// Graines. creerPcg32(graine, FLUX.GRAINES) tire une graine dérivée par
// proposition, la i-ème au i-ème tirage. La proposition i porte l'identifiant
// i et tire tout le reste, placements et mouvements, de
// creerPcg32(graine dérivée, FLUX.RECHERCHE). Elle ne dépend donc que de la
// configuration, de sa graine dérivée, du compte d'arrêt et de la longueur de
// l'historique, trois réglages qu'elle porte : la proposition 2 d'une demande
// de 3 est celle d'une demande de 5, quel que soit l'ordre dans lequel les
// propositions s'achèvent, et regenerer en rend le plan à partir de ces trois
// champs seuls (§ 8.9). Les deux passent par la même descente, proposer.
//
// Sièges. Chaque tour offre S = Σ c_t sièges, rangés table par table dans
// l'ordre des tables. Les personnes que instance.fixe impose à un tour
// occupent les premiers sièges de leur table, par index croissant, et n'en
// bougent jamais. Les autres sièges sont mobiles : chacun porte une personne
// libre à ce tour, ou une place fantôme, siège vide. Le placement initial
// mélange les personnes libres de chaque tour par melanger, tour après tour,
// et les assied dans les sièges mobiles pris dans l'ordre ; les sièges qui
// restent sont des places fantômes. Sans place manquante, personne n'est en
// réserve.
//
// Mouvement. Un tour tiré par borne(R), puis deux sièges mobiles de ce tour,
// chacun par borne(m), m le nombre de ses sièges mobiles ; un tour de moins
// de deux sièges mobiles ne tire rien de plus. Deux sièges de la même table,
// ou deux places fantômes, font un mouvement évalué et rejeté, qui ne change
// rien. Sinon les deux occupants s'échangent : un échange avec une place
// fantôme déplace une personne vers le siège vide d'une autre table.
//
// Score. Un n-uplet de six entiers comparé dans l'ordre lexicographique, la
// forme exacte d'une pondération strictement hiérarchique, sans poids ni
// perte de précision :
// 0. le plus grand écart au plafond a priori, plafond a priori − rencontres ;
// 1. la somme des carrés de ces écarts ;
// 2. l'excédent de collisions, si separerAppartenances ;
// 3. les collisions cumulées, si separerAppartenances ;
// 4. Σ_paires max(0, M − 1), M les rencontres de la paire, si
// nouveauxVoisins, plus les retours choisis, si nouvelleTable ;
// 5. Σ_p r(p), la redondance d'appartenance, si varierAppartenances.
// Une composante désactivée vaut 0 ; aucune n'est négative, et le n-uplet
// nul ne s'améliore plus.
//
// Incrémental. L'état tient à jour ce dont le score dépend : la matrice des
// rencontres par paire ; rencontres(p) ; l'histogramme des écarts au plafond
// a priori, d'où leur maximum, et la somme de leurs carrés ; les collisions
// cumulées de même appartenance et leur excédent ; Σ_paires max(0, M − 1) ;
// les visites par (personne, table), d'où les retours choisis ; par
// personne, le nombre d'affiliés rencontrés de chaque appartenance, d'où
// |F(p)|, A(p) et Σ r(p). Un échange ne touche que les paires des deux tables
// concernées, au tour concerné : O(capacité). Ce score sert la boucle de
// recherche et rien d'autre ; scoreComplet recompose le même n-uplet à partir
// de la mesure qui fait foi, dont il lit chaque grandeur au lieu de la
// refaire (§ 5.10, § 13.2).
//
// Acceptation tardive. L'historique H, de longueur L, part du score initial.
// À l'itération i, le candidat est gardé s'il ne dépasse ni le score courant
// ni H[i mod L], défait sinon ; puis H[i mod L] reçoit le score courant. La
// meilleure affectation vue, dans l'ordre du score, est conservée. La
// recherche s'arrête après arret mouvements évalués, ou dès que le score
// courant est le n-uplet nul : sur un compte, jamais sur une durée (§ 5.10).
//
// Deux ordres d'acceptation. L'acceptation compare d'abord dans l'ordre du
// score, rencontres en tête. Quand la meilleure affectation porte un écart
// maximal nul, chacun atteint son plafond a priori : les deux premières
// composantes sont à leur minimum prouvé (§ 5.5), et une meilleure
// affectation ne peut plus différer que sur les suivantes. L'acceptation
// compare alors les composantes 2, 3 et 4, celles des contraintes, avant les
// deux premières ; l'historique garde son contenu. L'ordre des contraintes
// laisse perdre une rencontre le temps de défaire une collision. L'ordre du
// score le refuse dès que l'historique ne porte plus que des scores à écart
// nul : collisions, voisins et tables n'y progressent plus que par les rares
// échanges qui conservent toutes les rencontres, et s'arrêtent loin de leur
// minimum. La meilleure affectation, jugée dans l'ordre du score, ne retient
// que des affectations à écart nul. Une instance dont aucun plan n'annule
// tous les écarts garde l'ordre du score de bout en bout.
//
// Relance. Dans l'ordre des contraintes, un score courant inchangé pendant
// PATIENCE × L itérations fait relancer la descente : le reste du compte
// sert une nouvelle descente, depuis un nouveau placement tiré du même
// générateur, l'historique au score de ce placement ; la meilleure
// affectation, et l'ordre des contraintes qu'elle fixe, sont conservés. Une
// descente de cet ordre s'arrête parfois sur un minimum local des voisins et
// des tables, des rencontres en deçà de leur plafond ; une descente neuve en
// est une tentative indépendante. Dans l'ordre du score, la relance ne joue
// pas : les échanges de même score y sont acceptés, un score courant
// inchangé y marque aussi bien un plateau que la descente parcourt, et une
// relance couperait ce parcours, d'autant plus souvent que L est court.
import { FLUX, creerPcg32 } from '../demo/prng.js';
import {
LIBRE,
RESERVE,
SANS_GROUPE,
nombrePlacesManquantes,
normaliser,
planDepuisIndex,
} from './configuration.js';
import { ErreurAnnulee, ErreurConfiguration } from './erreurs.js';
import { mesurer } from './indicateurs.js';
import { ecartsAuPlafondAPriori } from './manque.js';
import { plafondsAPriori } from './plafond.js';
/**
* @typedef {Object} ReglagesGeneration
* @property {number} graine entier 0 ≤ g < 2^32
* @property {number} arret mouvements évalués par proposition, ≥ 1
* @property {number} nombre propositions demandées, ≥ 1
* @property {number} [historique=1000] longueur de l'historique d'acceptation
*
* @typedef {Object} Proposition
* @property {number} id 1..nombre, place dans la suite des graines dérivées
* @property {number} graine graine dérivée de cette proposition
* @property {number} arret
* @property {number} historique longueur de l'historique d'acceptation
* @property {import('./types.js').Plan} plan
*
* @typedef {Object} EtatRecherche
* Rendu par creerEtat ; les champs qui suivent se lisent, aucun ne s'écrit
* hors de ce module. Un champ qui tient une grandeur que mesurer rend en
* porte le nom.
* @property {Int32Array} tableDe plan indexé courant, N × R
* @property {Int32Array} rencontres |met(p)|, ordre canonique
* @property {Int32Array} affilies |F(p)|
* @property {Int32Array} appartenancesVues A(p)
* @property {number} collisionsCumulees
* @property {number} excedentCollisions collisions cumulées − paires distinctes
* @property {number} totalRetoursChoisis
* @property {number} totalRedondance Σ_p r(p)
*
* @typedef {{tour: number, a: number, b: number}} Mouvement
* l'échange des occupants des sièges a et b au tour d'index tour
*/
// Siège sans occupant : la place fantôme.
const FANTOME = -1;
const TAILLE_SCORE = 6;
// Ordres de comparaison du n-uplet, par rang de composante : celui du score,
// et celui des contraintes, qui passe les composantes 2, 3 et 4 devant les
// deux premières.
const ORDRE_SCORE = Object.freeze([0, 1, 2, 3, 4, 5]);
const ORDRE_CONTRAINTES = Object.freeze([2, 3, 4, 0, 1, 5]);
// Itérations entre deux appels de progression, et entre deux lectures du
// signal.
const CADENCE = 1_000;
const HISTORIQUE_PAR_DEFAUT = 1_000;
// Longueurs d'historique sans changement du score courant au-delà desquelles
// une descente se relance, dans l'ordre des contraintes seulement.
const PATIENCE = 10;
const MOT_MAX = 0xffffffff;
// Plus grand compte de rencontres d'une paire que tient un Uint16Array : une
// paire se réunit au plus une fois par tour.
const UINT16_MAX = 0xffff;
// Valeur citée dans un message : un nombre tel quel, toute autre valeur
// suivie de son type.
const decrire = (valeur) =>
typeof valeur === 'number' ? String(valeur) : `${String(valeur)} (${typeof valeur})`;
// Lève une RangeError qui nomme le réglage quand la valeur n'est pas un
// entier de min à max.
function exigerReglage(nom, valeur, min, max) {
if (!Number.isInteger(valeur) || valeur < min || valeur > max) {
throw new RangeError(`${nom} : entier de ${min} à ${max} attendu, reçu ${decrire(valeur)}`);
}
}
// Lève une RangeError qui nomme le premier des trois réglages qu'une
// proposition porte à sortir de son domaine, contrôlés dans cet ordre :
// graine de 0 à 2^32 − 1, arret et historique ≥ 1. rechercher et regenerer
// l'appellent tous deux : ce que l'un admet, l'autre l'admet, et regenerer
// accepte les champs de toute proposition que rechercher rend.
function exigerChampsProposition({ graine, arret, historique }) {
exigerReglage('graine', graine, 0, MOT_MAX);
exigerReglage('arret', arret, 1, Number.MAX_SAFE_INTEGER);
exigerReglage('historique', historique, 1, Number.MAX_SAFE_INTEGER);
}
// Ce que toutes les propositions d'une instance partagent : la disposition
// des sièges, les sièges fixés et les sièges mobiles de chaque tour, les
// personnes libres de chaque tour par index croissant, le plafond a priori
// de chacun et, par (personne, table), le seuil de visites au-delà duquel
// une visite est un retour choisi. Lève PLACES_MANQUANTES quand les sièges
// d'un tour ne suffisent pas à asseoir tout le monde (§ 5.9).
function preparer(instance) {
const placesManquantes = nombrePlacesManquantes(instance);
if (placesManquantes > 0) throw new ErreurConfiguration('PLACES_MANQUANTES', { placesManquantes });
const { N, T, R, capacite, fixe } = instance;
const debut = new Int32Array(T + 1);
for (let t = 0; t < T; t += 1) debut[t + 1] = debut[t] + capacite[t];
const S = debut[T];
const tableDuSiege = new Int32Array(S);
for (let t = 0; t < T; t += 1) tableDuSiege.fill(t, debut[t], debut[t + 1]);
// siegesFixes[r × S + s] : la personne fixée au siège s du tour r,
// FANTOME pour un siège mobile. mobiles[r × S + i], i < nombreMobiles[r] :
// le i-ème siège mobile du tour r, dans l'ordre des sièges.
const siegesFixes = new Int32Array(R * S).fill(FANTOME);
const mobiles = new Int32Array(R * S);
const nombreMobiles = new Int32Array(R);
const libres = [];
const occupes = new Int32Array(T);
for (let r = 0; r < R; r += 1) {
occupes.fill(0);
const libresDuTour = [];
for (let p = 0; p < N; p += 1) {
const t = fixe[p * R + r];
if (t === LIBRE) {
libresDuTour.push(p);
} else {
siegesFixes[r * S + debut[t] + occupes[t]] = p;
occupes[t] += 1;
}
}
libres.push(libresDuTour);
let m = 0;
for (let s = 0; s < S; s += 1) {
if (siegesFixes[r * S + s] === FANTOME) {
mobiles[r * S + m] = s;
m += 1;
}
}
nombreMobiles[r] = m;
}
// Pour une table visitée m fois, dont f à un tour où la personne y est
// fixée, les retours choisis valent m − 1 − max(0, f − 1) (indicateurs.js),
// soit max(0, m − max(1, f)) : le seuil vaut max(1, f).
const seuilRetour = new Int32Array(N * T);
for (let p = 0; p < N; p += 1) {
for (let r = 0; r < R; r += 1) {
const t = fixe[p * R + r];
if (t !== LIBRE) seuilRetour[p * T + t] += 1;
}
}
for (let i = 0; i < seuilRetour.length; i += 1) {
if (seuilRetour[i] === 0) seuilRetour[i] = 1;
}
const aPriori = Int32Array.from(plafondsAPriori(instance));
let plusGrand = 0;
for (let p = 0; p < N; p += 1) plusGrand = Math.max(plusGrand, aPriori[p]);
return {
instance,
N,
T,
R,
S,
debut,
tableDuSiege,
siegesFixes,
mobiles,
nombreMobiles,
libres,
seuilRetour,
aPriori,
largeurHistogramme: plusGrand + 1,
};
}
// Les écarts au plafond a priori restent dans 0..aPriori[p] : sur un plan
// qui honore fixe et les capacités, rencontres ≤ plafond réalisé ≤ plafond a
// priori (plafond.js). Une hausse des rencontres ne fait baisser l'écart que
// d'un : quand le maximum perd son dernier porteur, e − 1 en a un.
function changerRencontres(etat, p, sens) {
const { rencontres, histogramme } = etat;
const avant = etat.aPriori[p] - rencontres[p];
const apres = avant - sens;
rencontres[p] += sens;
histogramme[avant] -= 1;
histogramme[apres] += 1;
etat.carresEcartsAuPlafondAPriori += apres * apres - avant * avant;
if (apres > etat.ecartAuPlafondAPrioriMax) {
etat.ecartAuPlafondAPrioriMax = apres;
} else if (avant === etat.ecartAuPlafondAPrioriMax && histogramme[avant] === 0) {
etat.ecartAuPlafondAPrioriMax = apres;
}
}
// p commence (sens = 1) ou cesse (sens = −1) de compter parmi ses
// rencontres un affilié du groupe g : |F(p)| suit, A(p) quand c'est le
// premier ou le dernier de ce groupe, et Σ r(p) = Σ (|F(p)| − A(p)) avec eux.
function changerAffilie(etat, p, g, sens) {
const i = p * etat.G + g;
const avant = etat.parGroupe[i];
etat.parGroupe[i] = avant + sens;
etat.affilies[p] += sens;
let vues = 0;
if (avant === (sens > 0 ? 0 : 1)) {
etat.appartenancesVues[p] += sens;
vues = sens;
}
etat.totalRedondance += sens - vues;
}
// Ajoute sens, 1 ou −1, aux rencontres de la paire (a, b), a ≠ b, et
// propage : les collisions cumulées pour deux personnes de la même
// appartenance. Quand la paire était réunie avant un ajout, ou le reste
// après un retrait, la rencontre ajoutée ou retirée est une répétition :
// elle compte dans Σ max(0, M − 1) et, entre collègues, dans l'excédent de
// collisions, qui vaut Σ max(0, M − 1) sur les paires de même appartenance.
// Sinon la paire devient réunie ou cesse de l'être : les rencontres et les
// affiliés des deux suivent.
function modifierPaire(etat, a, b, sens) {
const k = a < b ? a * etat.N + b : b * etat.N + a;
const avant = etat.paires[k];
const apres = avant + sens;
etat.paires[k] = apres;
const ga = etat.groupe[a];
const gb = etat.groupe[b];
const memeGroupe = ga === gb && etat.porteAppartenance[a] === 1;
if (memeGroupe) etat.collisionsCumulees += sens;
if ((sens > 0 ? avant : apres) !== 0) {
etat.excedentPaires += sens;
if (memeGroupe) etat.excedentCollisions += sens;
return;
}
changerRencontres(etat, a, sens);
changerRencontres(etat, b, sens);
if (etat.porteAppartenance[b] === 1) changerAffilie(etat, a, gb, sens);
if (etat.porteAppartenance[a] === 1) changerAffilie(etat, b, ga, sens);
}
// Ajoute sens aux visites de p à la table t, et ajuste les retours choisis.
function changerVisite(etat, p, t, sens) {
const i = p * etat.T + t;
const avant = etat.visites[i];
const apres = avant + sens;
etat.visites[i] = apres;
const seuil = etat.seuilRetour[i];
etat.totalRetoursChoisis += Math.max(0, apres - seuil) - Math.max(0, avant - seuil);
}
// Ajoute sens aux rencontres de p avec chaque occupant de la table t au tour
// dont les sièges commencent à base, le siège exclu mis à part.
function voisiner(etat, p, base, t, exclu, sens) {
const { siege, debut } = etat;
for (let s = debut[t], fin = debut[t + 1]; s < fin; s += 1) {
if (s === exclu) continue;
const q = siege[base + s];
if (q !== FANTOME) modifierPaire(etat, p, q, sens);
}
}
// Échange les occupants des sièges a et b, de deux tables différentes, au
// tour r. Chaque occupant quitte d'abord ses voisins, puis rejoint ceux de
// l'autre table : l'échange est sa propre réciproque, et le refaire rend
// l'état d'avant, compteurs compris.
function echanger(etat, r, a, b) {
const { siege, tableDe, tableDuSiege, R } = etat;
const base = r * etat.S;
const x = siege[base + a];
const y = siege[base + b];
const ta = tableDuSiege[a];
const tb = tableDuSiege[b];
if (x !== FANTOME) voisiner(etat, x, base, ta, a, -1);
if (y !== FANTOME) voisiner(etat, y, base, tb, b, -1);
siege[base + a] = y;
siege[base + b] = x;
if (x !== FANTOME) {
tableDe[x * R + r] = tb;
changerVisite(etat, x, ta, -1);
changerVisite(etat, x, tb, 1);
voisiner(etat, x, base, tb, b, 1);
}
if (y !== FANTOME) {
tableDe[y * R + r] = ta;
changerVisite(etat, y, tb, -1);
changerVisite(etat, y, ta, 1);
voisiner(etat, y, base, ta, a, 1);
}
}
// Placement initial d'une instance préparée : les personnes fixées à leurs
// sièges, les libres de chaque tour mélangées puis assises dans l'ordre des
// sièges mobiles. Les compteurs partent de zéro et suivent chaque paire et
// chaque visite par les mêmes fonctions que les mouvements ; les écarts
// partent du plafond a priori, aucune rencontre n'étant encore comptée.
function etatInitial(structure, rng) {
const { instance, N, T, R, S, mobiles, libres, aPriori, tableDuSiege, debut } = structure;
const G = instance.groupes.length;
const etat = {
instance,
contraintes: instance.contraintes,
groupe: instance.groupe,
// porteAppartenance[p] vaut 1 quand p porte une appartenance, 0 pour
// SANS_GROUPE.
// modifierPaire le lit au lieu de comparer un groupe à SANS_GROUPE : sous
// le lanceur d'épreuves, chaque lecture d'une valeur importée passe par
// un accesseur, que la boucle de recherche paierait à chaque paire.
porteAppartenance: Uint8Array.from(instance.groupe, (g) => (g === SANS_GROUPE ? 0 : 1)),
N,
T,
R,
S,
G,
debut,
tableDuSiege,
mobiles,
nombreMobiles: structure.nombreMobiles,
seuilRetour: structure.seuilRetour,
aPriori,
siege: Int32Array.from(structure.siegesFixes),
tableDe: new Int32Array(N * R),
paires: R <= UINT16_MAX ? new Uint16Array(N * N) : new Uint32Array(N * N),
rencontres: new Int32Array(N),
affilies: new Int32Array(N),
appartenancesVues: new Int32Array(N),
parGroupe: new Int32Array(N * G),
visites: new Int32Array(N * T),
histogramme: new Int32Array(structure.largeurHistogramme),
ecartAuPlafondAPrioriMax: 0,
carresEcartsAuPlafondAPriori: 0,
collisionsCumulees: 0,
excedentCollisions: 0,
excedentPaires: 0,
totalRetoursChoisis: 0,
totalRedondance: 0,
};
for (let p = 0; p < N; p += 1) {
etat.histogramme[aPriori[p]] += 1;
etat.carresEcartsAuPlafondAPriori += aPriori[p] * aPriori[p];
etat.ecartAuPlafondAPrioriMax = Math.max(etat.ecartAuPlafondAPrioriMax, aPriori[p]);
}
const { siege, tableDe } = etat;
for (let r = 0; r < R; r += 1) {
const melanges = rng.melanger(Int32Array.from(libres[r]));
for (let i = 0; i < melanges.length; i += 1) siege[r * S + mobiles[r * S + i]] = melanges[i];
}
for (let r = 0; r < R; r += 1) {
const base = r * S;
for (let s = 0; s < S; s += 1) {
const p = siege[base + s];
if (p === FANTOME) continue;
const t = tableDuSiege[s];
tableDe[p * R + r] = t;
changerVisite(etat, p, t, 1);
for (let voisin = debut[t]; voisin < s; voisin += 1) {
const q = siege[base + voisin];
if (q !== FANTOME) modifierPaire(etat, p, q, 1);
}
}
}
return etat;
}
// Écrit dans sortie le n-uplet du score, à partir des grandeurs nommées de
// q, et le rend. Une seule composition sert le score tenu à jour et le score
// recalculé ; une grandeur que mesurer rend y porte son nom, et la
// composition n'en refait aucune.
function composer(contraintes, q, sortie) {
const separer = contraintes.separerAppartenances;
sortie[0] = q.ecartAuPlafondAPrioriMax;
sortie[1] = q.carresEcartsAuPlafondAPriori;
sortie[2] = separer ? q.excedentCollisions : 0;
sortie[3] = separer ? q.collisionsCumulees : 0;
sortie[4] = (contraintes.nouveauxVoisins ? q.excedentPaires : 0)
+ (contraintes.nouvelleTable ? q.totalRetoursChoisis : 0);
sortie[5] = contraintes.varierAppartenances ? q.totalRedondance : 0;
return sortie;
}
// Signe de la comparaison lexicographique des n-uplets a[ia …] et b[ib …],
// composantes prises dans l'ordre que donne la liste ordre.
function comparer(ordre, a, ia, b, ib) {
for (let j = 0; j < TAILLE_SCORE; j += 1) {
const k = ordre[j];
const difference = a[ia + k] - b[ib + k];
if (difference !== 0) return difference;
}
return 0;
}
// Ordre d'acceptation que fixe la meilleure affectation : celui des
// contraintes dès que son écart maximal est nul (en-tête du module). Cet
// écart ne fait que baisser d'une meilleure affectation à la suivante : une
// fois nul, il le reste.
const ordreAccepte = (meilleurScore) =>
meilleurScore[0] === 0 ? ORDRE_CONTRAINTES : ORDRE_SCORE;
function estNul(score) {
for (let k = 0; k < TAILLE_SCORE; k += 1) if (score[k] !== 0) return false;
return true;
}
// Lit signal.aborted une fois, et lève ErreurAnnulee s'il est vrai.
function lireSignal(signal) {
if (signal?.aborted) throw new ErreurAnnulee('recherche annulée');
}
// Descente par acceptation tardive depuis un placement initial tiré de rng ;
// rend la meilleure affectation vue, en plan indexé. L'ordre d'acceptation
// suit la meilleure affectation (ordreAccepte). Dans l'ordre des
// contraintes, un score courant inchangé pendant PATIENCE × historique
// itérations fait relancer la descente (en-tête du module). La bascule vers
// cet ordre suit une meilleure affectation, donc un changement du score
// courant, qui remet immobile à zéro. fait compte les mouvements évalués
// depuis le début de la génération, à partir de dejaFaits, ceux des
// propositions précédentes : à chaque multiple de CADENCE,
// progression(fait, total), puis une lecture du signal.
function descendre(structure, rng, { arret, historique, dejaFaits, total, signal, progression }) {
const { contraintes } = structure.instance;
let etat = etatInitial(structure, rng);
const courant = composer(contraintes, etat, new Float64Array(TAILLE_SCORE));
const candidat = new Float64Array(TAILLE_SCORE);
const meilleurScore = Float64Array.from(courant);
const meilleurPlan = Int32Array.from(etat.tableDe);
const memoire = new Float64Array(historique * TAILLE_SCORE);
const remplirMemoire = () => {
for (let v = 0; v < memoire.length; v += TAILLE_SCORE) memoire.set(courant, v);
};
// Garde l'état courant comme meilleure affectation s'il la dépasse dans
// l'ordre du score, et rend l'ordre d'acceptation qui en découle.
const retenir = (ordre) => {
if (comparer(ORDRE_SCORE, courant, 0, meilleurScore, 0) >= 0) return ordre;
meilleurScore.set(courant);
meilleurPlan.set(etat.tableDe);
return ordreAccepte(meilleurScore);
};
remplirMemoire();
let ordre = ordreAccepte(meilleurScore);
const patience = PATIENCE * historique;
let immobile = 0;
for (let i = 0; i < arret && !estNul(courant); i += 1) {
if (immobile >= patience && ordre === ORDRE_CONTRAINTES) {
etat = etatInitial(structure, rng);
composer(contraintes, etat, courant);
remplirMemoire();
ordre = retenir(ordre);
immobile = 0;
}
const mouvement = proposerEtAppliquer(etat, rng);
const v = (i % historique) * TAILLE_SCORE;
let change = false;
if (mouvement !== null) {
composer(contraintes, etat, candidat);
if (comparer(ordre, candidat, 0, courant, 0) <= 0
|| comparer(ordre, candidat, 0, memoire, v) <= 0) {
change = comparer(ORDRE_SCORE, candidat, 0, courant, 0) !== 0;
courant.set(candidat);
ordre = retenir(ordre);
} else {
defaire(etat, mouvement);
}
}
memoire.set(courant, v);
immobile = change ? 0 : immobile + 1;
const fait = dejaFaits + i + 1;
if (fait % CADENCE === 0) {
progression?.(fait, total);
lireSignal(signal);
}
}
return meilleurPlan;
}
// Le plan de la proposition de graine dérivée graineDerivee : la descente
// depuis creerPcg32(graineDerivee, FLUX.RECHERCHE), rendue par identifiants.
// rechercher et regenerer l'appellent tous deux. À instance égale, le plan ne
// dépend que de la graine dérivée, d'options.arret et d'options.historique :
// les autres options ne servent qu'à rapporter l'avancement et à lire le
// signal.
function proposer(instance, structure, graineDerivee, options) {
const rng = creerPcg32(graineDerivee, FLUX.RECHERCHE);
return planDepuisIndex(instance, descendre(structure, rng, options));
}
/**
* Propositions de placement d'une configuration (§ 5.7) : nombre
* propositions, d'identifiants 1 à nombre, chacune la meilleure affectation
* vue en arret mouvements évalués, relances comprises (en-tête du module).
* Chacune porte sa graine dérivée, arret et historique, défaut résolu : les
* trois décident de son plan, que regenerer rend à partir d'eux. À
* configuration et réglages égaux, le résultat est identique. Ne modifie pas
* la configuration.
*
* Toutes les 1 000 itérations, comptées sur la génération entière,
* progression(fait, total) reçoit le rang du mouvement en cours parmi
* total = nombre × arret ; une proposition arrêtée tôt sur un score nul
* laisse la suivante partir de son propre rang, (id − 1) × arret. Le signal
* se lit à chacun de ces instants et avant chaque proposition : vrai, il
* fait lever ErreurAnnulee, et aucune proposition n'est rendue (§ 5.10).
*
* Lève RangeError quand un réglage n'est pas un entier de son domaine —
* graine de 0 à 2^32 − 1, arret, nombre et historique ≥ 1 ;
* ErreurConfiguration de normaliser ; ErreurConfiguration
* ('PLACES_MANQUANTES', { placesManquantes }) quand les tables n'offrent pas
* assez de sièges, placesManquantes étant le nombre de places qui manquent à
* chaque tour, celui que rend nombrePlacesManquantes.
*
* @param {import('./types.js').Configuration} configuration
* @param {ReglagesGeneration} reglages
* @param {{signal?: {aborted: boolean}, progression?: function(number, number): void}} [options]
* @returns {Proposition[]}
*/
export function rechercher(configuration, reglages, { signal, progression } = {}) {
const { graine, arret, nombre, historique = HISTORIQUE_PAR_DEFAUT } = reglages;
exigerChampsProposition({ graine, arret, historique });
exigerReglage('nombre', nombre, 1, Number.MAX_SAFE_INTEGER);
const instance = normaliser(configuration);
const structure = preparer(instance);
const graines = creerPcg32(graine, FLUX.GRAINES);
const total = nombre * arret;
const propositions = [];
for (let id = 1; id <= nombre; id += 1) {
lireSignal(signal);
const graineDerivee = graines.suivant();
const plan = proposer(instance, structure, graineDerivee, {
arret,
historique,
dejaFaits: (id - 1) * arret,
total,
signal,
progression,
});
propositions.push({ id, graine: graineDerivee, arret, historique, plan });
}
return propositions;
}
/**
* Le plan d'une proposition, régénéré à partir de ses seuls champs (§ 5.7,
* § 8.9) : la descente part de proposition.graine, la graine dérivée qu'elle
* porte, et compte proposition.arret mouvements sous un historique de
* proposition.historique cases. Ces trois champs décident du plan : à
* configuration égale, regenerer rend le plan que rechercher a rendu pour
* cette proposition, quel que soit son rang dans sa génération. La descente
* elle-même fait aussi partie du résultat, ordre des tirages, PATIENCE et
* ordres d'acceptation : une proposition rendue par une autre version de ce
* module n'est pas garantie régénérable (§ 8.9). Aucun autre champ n'est lu,
* ni id ni plan. Ne modifie ni la configuration ni la proposition.
*
* Lève RangeError quand l'un des trois champs n'est pas un entier de son
* domaine, graine de 0 à 2^32 − 1, arret et historique ≥ 1 : un champ absent
* ne reçoit pas la valeur par défaut de rechercher, qui ferait régénérer une
* autre proposition sans le dire. Lève ce que rechercher lève pour la
* configuration : ErreurConfiguration de normaliser, PLACES_MANQUANTES.
*
* @param {import('./types.js').Configuration} configuration
* @param {{graine: number, arret: number, historique: number}} proposition
* @returns {import('./types.js').Plan}
*/
export function regenerer(configuration, { graine, arret, historique }) {
exigerChampsProposition({ graine, arret, historique });
const instance = normaliser(configuration);
return proposer(instance, preparer(instance), graine, {
arret,
historique,
dejaFaits: 0,
total: arret,
});
}
/**
* État de recherche au placement initial (§ 5.10) : fixés à leurs tables,
* libres mélangés par rng.melanger, tour après tour, puis assis table après
* table dans l'ordre des tables. Lève ErreurConfiguration('PLACES_MANQUANTES',
* { placesManquantes }) comme rechercher.
*
* @param {import('./types.js').Instance} instance
* @param {ReturnType<typeof creerPcg32>} rng
* @returns {EtatRecherche}
*/
export function creerEtat(instance, rng) {
return etatInitial(preparer(instance), rng);
}
/**
* Le n-uplet du score, tel que l'état le tient à jour.
*
* @param {EtatRecherche} etat
* @returns {number[]}
*/
export function scoreIncremental(etat) {
return composer(etat.contraintes, etat, new Array(TAILLE_SCORE));
}
/**
* Le même n-uplet, recalculé de zéro sur etat.tableDe à partir de la mesure
* qui fait foi, dont chaque grandeur se lit au lieu de se refaire : les
* écarts au plafond a priori par ecartsAuPlafondAPriori (manque.js), sur les
* plafonds de plafondsAPriori ; l'excédent de collisions, les collisions
* cumulées, les retours choisis et Σ r(p) par mesurer. Une redéfinition de
* l'une d'elles dans son module change donc ce score, et son accord avec le
* score tenu à jour tombe.
*
* Seule Σ_paires max(0, M − 1), que la mesure ne rend pas, se calcule ici :
* elle vaut Σ_paires M − Σ_paires min(M, 1). Le premier terme compte les
* paires réunies à chaque table de chaque tour, o(o − 1)/2 pour o
* occupants ; le second, les paires réunies au moins une fois,
* Σ_p rencontres(p) / 2.
*
* @param {EtatRecherche} etat
* @returns {number[]}
*/
export function scoreComplet(etat) {
const { instance, tableDe } = etat;
const { N, T, R } = instance;
const mesures = mesurer(instance, tableDe);
let ecartAuPlafondAPrioriMax = 0;
let carresEcartsAuPlafondAPriori = 0;
for (const ecart of ecartsAuPlafondAPriori(mesures, plafondsAPriori(instance))) {
ecartAuPlafondAPrioriMax = Math.max(ecartAuPlafondAPrioriMax, ecart);
carresEcartsAuPlafondAPriori += ecart * ecart;
}
const occupation = new Int32Array(T * R);
for (let p = 0; p < N; p += 1) {
for (let r = 0; r < R; r += 1) {
const t = tableDe[p * R + r];
if (t !== RESERVE) occupation[t * R + r] += 1;
}
}
let reunions = 0;
for (const o of occupation) reunions += (o * (o - 1)) / 2;
let sommeRencontres = 0;
for (const rencontres of mesures.rencontres) sommeRencontres += rencontres;
const grandeurs = {
ecartAuPlafondAPrioriMax,
carresEcartsAuPlafondAPriori,
excedentCollisions: mesures.excedentCollisions,
collisionsCumulees: mesures.collisionsCumulees,
excedentPaires: reunions - sommeRencontres / 2,
totalRetoursChoisis: mesures.totalRetoursChoisis,
totalRedondance: mesures.totalRedondance,
};
return composer(instance.contraintes, grandeurs, new Array(TAILLE_SCORE));
}
/**
* Un mouvement (en-tête du module), appliqué à l'état. Rend de quoi le
* défaire, ou null pour un mouvement évalué et rejeté, qui n'a rien changé.
*
* @param {EtatRecherche} etat
* @param {ReturnType<typeof creerPcg32>} rng
* @returns {Mouvement|null}
*/
export function proposerEtAppliquer(etat, rng) {
const { R, S, mobiles, nombreMobiles, tableDuSiege, siege } = etat;
const r = rng.borne(R);
const m = nombreMobiles[r];
if (m < 2) return null;
const base = r * S;
const a = mobiles[base + rng.borne(m)];
const b = mobiles[base + rng.borne(m)];
if (tableDuSiege[a] === tableDuSiege[b]) return null;
if (siege[base + a] === FANTOME && siege[base + b] === FANTOME) return null;
echanger(etat, r, a, b);
return { tour: r, a, b };
}
/**
* Défait mouvement, le dernier appliqué à l'état : l'état revient à
* l'identique, compteurs compris. Une suite de mouvements se défait à
* rebours, le dernier d'abord. Un mouvement null ne fait rien.
*
* @param {EtatRecherche} etat
* @param {Mouvement|null} mouvement
*/
export function defaire(etat, mouvement) {
if (mouvement !== null) echanger(etat, mouvement.tour, mouvement.a, mouvement.b);
}

View file

@ -0,0 +1,156 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves lourdes de la recherche (§ 5.10, § 14.10, § 15.3). Sur chacune
// des quatre démonstrations, une suite déterministe de mouvements, acceptés
// ou défaits, confronte l'état tenu à jour au recalcul complet à 1, 10, 100,
// 1 000 et 10 000 mouvements appliqués, et non au seul terme : une dérive qui
// se compense passe inaperçue à la fin, et le palier où un désaccord affleure
// est le chiffre que l'épreuve rend. Le relevé liste chaque désaccord, palier
// et grandeur, au lieu de s'arrêter au premier. La petite démonstration et sa
// variante conflit atteignent ensuite leur optimum sur vingt graines, trois
// propositions chacune ; une recherche longue sur la grande démonstration
// rend des plans que jugent verifierInvariants et verifierIndicateurs.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { CATALOGUE } from '../demo/catalogue.js';
import { FLUX, creerPcg32 } from '../demo/prng.js';
import { indexerPlan, normaliser } from './configuration.js';
import { mesurer } from './indicateurs.js';
import { plafondsAPriori, plafondsRealises } from './plafond.js';
import {
creerEtat,
defaire,
proposerEtAppliquer,
rechercher,
scoreComplet,
scoreIncremental,
} from './recherche.js';
import { verifierIndicateurs, verifierInvariants } from './verification.js';
const PALIERS = [1, 10, 100, 1_000, 10_000];
// Graine des suites de mouvements, écrite ici.
const GRAINE = 271_828;
// Un mouvement appliqué sur trois est défait : la suite passe par les deux
// chemins, l'acceptation et le retour en arrière.
const DEFAIT_TOUS_LES = 3;
// Propositions de mouvement au-delà desquelles une suite renonce : une
// instance où aucun mouvement ne s'applique n'atteindrait jamais le dernier
// palier.
const ESSAIS_MAX = 100 * PALIERS.at(-1);
// Vrai quand deux listes de nombres ont la même longueur et les mêmes
// valeurs, rang par rang, chacune un nombre fini : deux champs absents, deux
// undefined, ne s'accordent pas.
const egales = (a, b) =>
a.length === b.length && a.every((valeur, i) => Number.isFinite(valeur) && valeur === b[i]);
// Ajoute à releve un libellé « palier : grandeur » pour chaque grandeur où
// l'état tenu à jour diffère du recalcul complet.
function relever(etat, instance, palier, releve) {
const mesures = mesurer(instance, etat.tableDe);
const confronter = (grandeur, tenue, recalculee) => {
if (!egales(tenue, recalculee)) releve.push(`${palier} : ${grandeur}`);
};
confronter('score', scoreIncremental(etat), scoreComplet(etat));
confronter('rencontres', Array.from(etat.rencontres), mesures.rencontres);
confronter('affilies', Array.from(etat.affilies), mesures.affilies);
confronter('appartenancesVues', Array.from(etat.appartenancesVues), mesures.appartenancesVues);
confronter('collisionsCumulees', [etat.collisionsCumulees], [mesures.collisionsCumulees]);
confronter('excedentCollisions', [etat.excedentCollisions], [mesures.excedentCollisions]);
confronter('totalRetoursChoisis', [etat.totalRetoursChoisis], [mesures.totalRetoursChoisis]);
confronter('totalRedondance', [etat.totalRedondance], [mesures.totalRedondance]);
}
describe('recherche : incrémental contre recalcul complet (§ 5.10, § 14.10)', () => {
test('le catalogue fournit les quatre démonstrations éprouvées ci-dessous', () => {
assert.equal(CATALOGUE.length, 4);
});
for (const { cle, construire } of CATALOGUE) {
test(`${cle} : accord à 1, 10, 100, 1 000 et 10 000 mouvements`, () => {
const instance = normaliser(construire());
const rng = creerPcg32(GRAINE, FLUX.RECHERCHE);
const etat = creerEtat(instance, rng);
const releve = [];
relever(etat, instance, 0, releve);
let appliques = 0;
let defaits = 0;
let palier = 0;
for (let essai = 0; palier < PALIERS.length && essai < ESSAIS_MAX; essai += 1) {
const mouvement = proposerEtAppliquer(etat, rng);
if (mouvement === null) continue;
appliques += 1;
if (appliques % DEFAIT_TOUS_LES === 0) {
defaire(etat, mouvement);
defaits += 1;
}
if (appliques === PALIERS[palier]) {
relever(etat, instance, appliques, releve);
palier += 1;
}
}
assert.equal(appliques, PALIERS.at(-1), `${cle} : mouvements appliqués`);
assert.equal(defaits, Math.floor(PALIERS.at(-1) / DEFAIT_TOUS_LES));
assert.deepEqual(releve, [], cle);
});
}
});
// Graines 0 à GRAINES − 1, trois propositions chacune : l'optimum se juge sur
// une suite de graines, et non sur une seule, qu'un réglage de l'algorithme
// pourrait servir par hasard.
const GRAINES = 20;
describe('recherche : la petite démonstration et sa variante, sur vingt graines (§ 15.3)', () => {
// Plancher de collisions cumulées : aucune dans la petite démonstration ;
// une par tour dans la variante, dont le groupe de cinq ne se répartit pas
// sur quatre tables, chacune d'une paire différente au mieux.
for (const [cle, plancher] of [['petite', 0], ['petite-conflit', 4]]) {
test(`${cle} : chaque proposition rencontre 8 pour les douze, sans répétition, ${plancher} collisions sur ${plancher} paires`, () => {
const configuration = CATALOGUE.find((entree) => entree.cle === cle).construire();
const instance = normaliser(configuration);
const manquees = [];
let examinees = 0;
for (let graine = 0; graine < GRAINES; graine += 1) {
for (const { id, plan } of rechercher(configuration, { graine, arret: 200_000, nombre: 3 })) {
examinees += 1;
const m = mesurer(instance, indexerPlan(instance, plan));
const optimale = m.rencontres.every((rencontres) => rencontres === 8)
&& m.maxRencontresPaire === 1
&& m.collisionsCumulees === plancher
&& m.pairesDistinctes === plancher;
if (!optimale) {
manquees.push(
`graine ${graine}, proposition ${id} : minimum ${m.aggRencontres.tous.min}, `
+ `collisions ${m.collisionsCumulees} sur ${m.pairesDistinctes} paires, `
+ `paire la plus revue ${m.maxRencontresPaire} fois`,
);
}
}
}
assert.equal(examinees, 3 * GRAINES);
assert.deepEqual(manquees, []);
});
}
});
describe('recherche : recherche longue sur la grande démonstration (§ 14.12)', () => {
test('arret 200 000 : plans valides, et rencontres ≤ plafond réalisé ≤ plafond a priori ≤ N − 1', () => {
const configuration = CATALOGUE.find((entree) => entree.cle === 'grande').construire();
const instance = normaliser(configuration);
const aPriori = plafondsAPriori(instance);
const propositions = rechercher(configuration, { graine: 31, arret: 200_000, nombre: 2 });
assert.equal(propositions.length, 2);
for (const { id, plan } of propositions) {
assert.deepEqual(verifierInvariants(instance, plan), [], `proposition ${id}`);
const tableDe = indexerPlan(instance, plan);
const mesures = mesurer(instance, tableDe);
assert.deepEqual(
verifierIndicateurs(mesures, aPriori, plafondsRealises(instance, tableDe)),
[],
`proposition ${id}`,
);
}
});
});

View file

@ -0,0 +1,931 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la recherche (§ 5.2, § 5.7, § 5.9, § 5.10, § 14.10) : à graine
// et entrée égales, propositions identiques ; l'identifiant d'une proposition
// est son rang dans la suite des graines dérivées, et son plan sort de sa
// graine dérivée, de son compte d'arrêt et de son historique, trois champs à
// partir desquels regenerer le rend ; chaque plan rendu tient les invariants
// et les réservations, et l'appartenance d'un ancré y pèse comme une autre ;
// une salle trop petite est refusée avec le nombre de places manquantes ;
// l'avancement se rapporte toutes les 1 000 itérations, une descente
// s'arrête sur un score nul, une annulation ne rend rien ; le placement
// initial égale celui que décrit le paragraphe « Sièges » de l'en-tête de
// recherche.js, récrit ici à la lettre ; un échange avec une place fantôme
// fait passer une personne à une autre table, comme le dit son paragraphe
// « Mouvement » ; la descente égale l'acceptation tardive de son paragraphe
// « Acceptation tardive », récrite de même, là où ni bascule ni relance ne
// jouent, et sinon augmentée de la bascule et de la relance de son
// paragraphe « Deux ordres d'acceptation » ; le score tenu à jour et le score
// recalculé égalent un n-uplet recalculé ici par énumération des paires ; la
// petite démonstration atteint son plan parfait sans cas particulier. Un plan
// rendu se juge par verifierInvariants et se mesure par mesurer, deux modules
// distincts du module éprouvé.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { CATALOGUE } from '../demo/catalogue.js';
import { FLUX, creerPcg32 } from '../demo/prng.js';
import {
LIBRE,
RESERVE,
SANS_GROUPE,
indexerPlan,
normaliser,
planDepuisIndex,
} from './configuration.js';
import { ErreurAnnulee } from './erreurs.js';
import { mesurer } from './indicateurs.js';
import { plafondsAPriori } from './plafond.js';
import {
creerEtat,
defaire,
proposerEtAppliquer,
rechercher,
regenerer,
scoreComplet,
scoreIncremental,
} from './recherche.js';
import { verifierInvariants } from './verification.js';
const SANS_CONTRAINTE = {
separerAppartenances: false,
nouveauxVoisins: false,
nouvelleTable: false,
varierAppartenances: false,
};
const TOUTES_CONTRAINTES = {
separerAppartenances: true,
nouveauxVoisins: true,
nouvelleTable: true,
varierAppartenances: true,
};
const NOMS_CONTRAINTES = [
'separerAppartenances',
'nouveauxVoisins',
'nouvelleTable',
'varierAppartenances',
];
// Longueur d'historique que rechercher prend quand les réglages n'en donnent
// pas (plan, ReglagesGeneration).
const HISTORIQUE_PAR_DEFAUT = 1_000;
// Configuration neuve d'une démonstration du catalogue.
const demo = (cle) => CATALOGUE.find((entree) => entree.cle === cle).construire();
const tous = (participant, table) => ({ participant, table, portee: 'tous' });
const auTour = (participant, table, tour) => ({ participant, table, portee: 'tour', tour });
// Configuration dont le participant d'id i + 1 porte appartenances[i] ; les
// tables reçoivent leurs numéros dans l'ordre de la liste ; un id cité dans
// exclus est exclu.
function configurationDe({
appartenances,
tables,
tours,
reservations = [],
contraintes = SANS_CONTRAINTE,
exclus = [],
}) {
return {
participants: appartenances.map((appartenance, i) => ({
id: i + 1,
nom: `P${i + 1}`,
appartenance,
exclu: exclus.includes(i + 1),
})),
tables: tables.map(({ id, capacite }, i) => ({ id, numero: i + 1, capacite })),
tours,
reservations,
contraintes: { ...contraintes },
};
}
// Trois tables, d'ids 10, 20 et 30 et de 4, 3 et 3 sièges, pour neuf
// participants sur trois tours : une place reste vide à chaque tour. Le
// participant 1 est ancré à la table 20 ; le 2 n'est réservé qu'au tour 2, à
// la table 10 ; le 3 l'est aux tours 1 et 3, à la table 30 ; le 4 au tour 1
// à la table 10 et au tour 3 à la table 20. Huit couples (réservation, tour).
const salleReservee = () =>
configurationDe({
appartenances: ['A', 'B', 'A', null, 'B', 'A', null, 'B', null],
tables: [
{ id: 10, capacite: 4 },
{ id: 20, capacite: 3 },
{ id: 30, capacite: 3 },
],
tours: 3,
reservations: [
tous(1, 20),
auTour(2, 10, 2),
auTour(3, 30, 1),
auTour(3, 30, 3),
auTour(4, 10, 1),
auTour(4, 20, 3),
],
contraintes: TOUTES_CONTRAINTES,
});
// Réservations qu'un plan n'honore pas, chacune « participant@table, tour
// n », et le nombre de couples (réservation, tour) examinés.
function reservationsRompues(configuration, plan) {
const rompues = [];
let examines = 0;
for (const { participant, table, portee, tour } of configuration.reservations) {
const numeros = portee === 'tous' ? plan.tours.map((_, r) => r + 1) : [tour];
for (const numero of numeros) {
examines += 1;
const liste = plan.tours[numero - 1][plan.tables.indexOf(table)];
if (!liste.includes(participant)) rompues.push(`${participant}@${table}, tour ${numero}`);
}
}
return { rompues, examines };
}
// Six participants sans appartenance, trois tables de 2, un tour, aucune
// contrainte : chacun rencontre son seul voisin, qui est son plafond a
// priori, quel que soit le placement. Le score est nul dès le placement
// initial, et chaque descente rend ce placement.
const troisTablesDeDeux = () =>
configurationDe({
appartenances: Array(6).fill(null),
tables: [
{ id: 1, capacite: 2 },
{ id: 2, capacite: 2 },
{ id: 3, capacite: 2 },
],
tours: 1,
});
// Placement initial du paragraphe « Sièges » de recherche.js, récrit à la
// lettre : tour après
// tour, les fixés à leur table ; les autres, par index croissant, mélangés
// par rng.melanger, puis assis table après table dans l'ordre des tables,
// aux places que les fixés laissent libres. Rend le plan indexé.
function placementLitteral(instance, rng) {
const { N, R, capacite, fixe } = instance;
const tableDe = new Int32Array(N * R);
for (let r = 0; r < R; r += 1) {
const restantes = Array.from(capacite);
const libres = [];
for (let p = 0; p < N; p += 1) {
const t = fixe[p * R + r];
if (t === LIBRE) {
libres.push(p);
} else {
tableDe[p * R + r] = t;
restantes[t] -= 1;
}
}
let t = 0;
for (const p of rng.melanger(libres)) {
while (restantes[t] === 0) t += 1;
tableDe[p * R + r] = t;
restantes[t] -= 1;
}
}
return tableDe;
}
// Le n-uplet du paragraphe « Score » de recherche.js, recalculé par
// énumération directe des
// paires du plan indexé, sans mesurer ni le module éprouvé ; les plafonds a
// priori sont la seule grandeur reçue. Rend aussi les sept grandeurs brutes,
// contraintes ignorées : écart maximal au plafond a priori, somme des carrés
// des écarts, excédent de collisions, collisions cumulées, Σ_paires
// max(0, M − 1), retours choisis, Σ_p r(p).
function scoreOracle(instance, tableDe, aPriori) {
const { N, T, R, groupe, fixe, contraintes } = instance;
// M[a × N + b], a < b : le nombre de tours où a et b partagent une table.
const M = new Int32Array(N * N);
for (let r = 0; r < R; r += 1) {
for (let a = 0; a < N; a += 1) {
for (let b = a + 1; b < N; b += 1) {
const t = tableDe[a * R + r];
if (t !== RESERVE && t === tableDe[b * R + r]) M[a * N + b] += 1;
}
}
}
const rencontres = Array(N).fill(0);
const affilies = Array(N).fill(0);
const appartenances = Array.from({ length: N }, () => new Set());
let cumulees = 0;
let distinctes = 0;
let excedentPaires = 0;
for (let a = 0; a < N; a += 1) {
for (let b = a + 1; b < N; b += 1) {
const m = M[a * N + b];
if (m === 0) continue;
excedentPaires += m - 1;
for (const [p, q] of [[a, b], [b, a]]) {
rencontres[p] += 1;
if (groupe[q] !== SANS_GROUPE) {
affilies[p] += 1;
appartenances[p].add(groupe[q]);
}
}
if (groupe[a] !== SANS_GROUPE && groupe[a] === groupe[b]) {
cumulees += m;
distinctes += 1;
}
}
}
// Une table visitée m fois, dont f à un tour où la personne y est
// réservée, compte m − 1 − max(0, f − 1) retours choisis (§ 5.4).
let retours = 0;
for (let p = 0; p < N; p += 1) {
for (let t = 0; t < T; t += 1) {
let visites = 0;
let reservees = 0;
for (let r = 0; r < R; r += 1) {
if (tableDe[p * R + r] !== t) continue;
visites += 1;
if (fixe[p * R + r] === t) reservees += 1;
}
if (visites > 0) retours += visites - 1 - Math.max(0, reservees - 1);
}
}
const ecarts = aPriori.map((plafond, p) => plafond - rencontres[p]);
const brutes = [
Math.max(...ecarts),
ecarts.reduce((somme, ecart) => somme + ecart * ecart, 0),
cumulees - distinctes,
cumulees,
excedentPaires,
retours,
affilies.reduce((somme, f, p) => somme + f - appartenances[p].size, 0),
];
const c = contraintes;
const nUplet = [
brutes[0],
brutes[1],
c.separerAppartenances ? brutes[2] : 0,
c.separerAppartenances ? brutes[3] : 0,
(c.nouveauxVoisins ? brutes[4] : 0) + (c.nouvelleTable ? brutes[5] : 0),
c.varierAppartenances ? brutes[6] : 0,
];
return { nUplet, brutes };
}
// Ordres de comparaison du n-uplet, par rang de composante : celui du score,
// et celui des contraintes, composantes 2, 3 et 4 devant les deux premières
// (en-tête de recherche.js).
const ORDRE_SCORE = [0, 1, 2, 3, 4, 5];
const ORDRE_CONTRAINTES = [2, 3, 4, 0, 1, 5];
// Longueurs d'historique sans changement du score courant au-delà desquelles
// une descente se relance (en-tête de recherche.js).
const PATIENCE = 10;
// Signe de la comparaison lexicographique de deux n-uplets, composantes
// prises dans l'ordre donné.
function comparerDans(ordre, a, b) {
for (const k of ordre) if (a[k] !== b[k]) return a[k] - b[k];
return 0;
}
// Une descente selon les paragraphes « Mouvement » à « Deux ordres
// d'acceptation » de recherche.js, écrite avec les primitives
// publiques : placement initial par creerEtat ; à l'itération i, un
// mouvement de proposerEtAppliquer, gardé si son score est ≤ courant ou
// ≤ H[i mod L] dans l'ordre d'acceptation, défait sinon ; puis H[i mod L]
// reçoit le score courant. La meilleure affectation change sur un score
// strictement meilleur dans l'ordre du score. Arrêt après arret mouvements
// évalués, ou sur le n-uplet nul.
//
// Sans bascule, l'ordre d'acceptation reste celui du score : c'est
// l'acceptation tardive seule, à la lettre (§ 5.10). Avec, il devient l'ordre des contraintes dès que la
// meilleure affectation porte un écart maximal nul ; dans cet ordre, un
// score courant inchangé pendant PATIENCE × L itérations fait repartir la
// descente d'un placement neuf, tiré du même générateur, l'historique à son
// score (en-tête de recherche.js). Rend le plan et trois comptes : bascules,
// relances, et meilleures affectations trouvées après une relance.
function descendreALaLettre(instance, graineDerivee, arret, L, { bascule }) {
const rng = creerPcg32(graineDerivee, FLUX.RECHERCHE);
let etat = creerEtat(instance, rng);
let courant = scoreIncremental(etat);
let meilleur = courant;
let meilleurPlan = Int32Array.from(etat.tableDe);
let H = Array(L).fill(courant);
let ordre = ORDRE_SCORE;
let immobile = 0;
const comptes = { bascules: 0, relances: 0, apresRelance: 0 };
const basculer = () => {
if (!bascule || ordre === ORDRE_CONTRAINTES || meilleur[0] !== 0) return;
ordre = ORDRE_CONTRAINTES;
comptes.bascules += 1;
};
const retenir = () => {
if (comparerDans(ORDRE_SCORE, courant, meilleur) >= 0) return;
meilleur = courant;
meilleurPlan = Int32Array.from(etat.tableDe);
if (comptes.relances > 0) comptes.apresRelance += 1;
basculer();
};
basculer();
for (let i = 0; i < arret && courant.some((composante) => composante !== 0); i += 1) {
if (immobile >= PATIENCE * L && ordre === ORDRE_CONTRAINTES) {
etat = creerEtat(instance, rng);
courant = scoreIncremental(etat);
H = Array(L).fill(courant);
comptes.relances += 1;
retenir();
immobile = 0;
}
const mouvement = proposerEtAppliquer(etat, rng);
let change = false;
if (mouvement !== null) {
const candidat = scoreIncremental(etat);
if (comparerDans(ordre, candidat, courant) <= 0 || comparerDans(ordre, candidat, H[i % L]) <= 0) {
change = comparerDans(ORDRE_SCORE, candidat, courant) !== 0;
courant = candidat;
retenir();
} else {
defaire(etat, mouvement);
}
}
H[i % L] = courant;
immobile = change ? 0 : immobile + 1;
}
return { plan: planDepuisIndex(instance, meilleurPlan), ...comptes };
}
// Les descentes à la lettre pour ces réglages, historique compris : la
// i-ème part de la i-ème graine que FLUX.GRAINES tire de graine (paragraphe
// « Graines » de recherche.js, § 5.7).
function descentesALaLettre(configuration, { graine, arret, nombre, historique }, options) {
const instance = normaliser(configuration);
const graines = creerPcg32(graine, FLUX.GRAINES);
return Array.from({ length: nombre }, () =>
descendreALaLettre(instance, graines.suivant(), arret, historique, options));
}
// Les plans de l'acceptation tardive seule, à la lettre : sans bascule ni
// relance.
const plansLitteraux = (configuration, reglages) =>
descentesALaLettre(configuration, reglages, { bascule: false }).map(({ plan }) => plan);
describe('rechercher : reproductibilité et identifiants (§ 5.7, § 19.4)', () => {
test('mêmes configuration et réglages : propositions identiques ; une autre graine change au moins un plan', () => {
const reglages = { graine: 11, arret: 2_000, nombre: 2 };
const premieres = rechercher(demo('grande'), reglages);
assert.equal(premieres.length, 2);
assert.deepStrictEqual(rechercher(demo('grande'), reglages), premieres);
const autres = rechercher(demo('grande'), { ...reglages, graine: 12 });
assert.notDeepStrictEqual(
autres.map((proposition) => proposition.plan),
premieres.map((proposition) => proposition.plan),
);
});
test("l'identifiant est le rang dans la suite des graines dérivées ; la proposition 2 de 3 égale celle de 5", () => {
const trois = rechercher(demo('petite'), { graine: 7, arret: 3_000, nombre: 3 });
const cinq = rechercher(demo('petite'), { graine: 7, arret: 3_000, nombre: 5 });
const graines = creerPcg32(7, FLUX.GRAINES);
const derivees = Array.from({ length: 5 }, () => graines.suivant());
assert.deepEqual(cinq.map((proposition) => proposition.id), [1, 2, 3, 4, 5]);
assert.deepEqual(cinq.map((proposition) => proposition.graine), derivees);
assert.deepEqual(cinq.map((proposition) => proposition.arret), [3_000, 3_000, 3_000, 3_000, 3_000]);
assert.deepStrictEqual(trois[1], cinq[1]);
assert.deepStrictEqual(trois, cinq.slice(0, 3));
});
test("chaque proposition porte la longueur d'historique qui l'a produite (§ 5.7, § 8.9)", () => {
// Graine, arret et historique décident ensemble du plan : une
// proposition qui ne porterait pas le troisième ne se régénérerait que
// sous la valeur par défaut.
const parDefaut = rechercher(demo('petite'), { graine: 7, arret: 500, nombre: 2 });
assert.deepEqual(parDefaut.map((proposition) => proposition.historique), [
HISTORIQUE_PAR_DEFAUT,
HISTORIQUE_PAR_DEFAUT,
]);
const reglee = rechercher(demo('petite'), { graine: 7, arret: 500, nombre: 2, historique: 37 });
assert.deepEqual(reglee.map((proposition) => proposition.historique), [37, 37]);
});
test('chaque proposition est le placement que sa graine dérivée tire par le flux de la recherche', () => {
// Score nul dès le placement : chaque descente rend son placement
// initial, que creerEtat redonne à partir de la seule graine de la
// proposition. Les quatre placements diffèrent : un générateur partagé
// entre les propositions, ou tiré d'une autre graine ou d'un autre flux,
// en donnerait d'autres.
const configuration = troisTablesDeDeux();
const instance = normaliser(configuration);
const propositions = rechercher(configuration, { graine: 21, arret: 1_000, nombre: 4 });
const attendus = propositions.map(({ graine }) =>
planDepuisIndex(instance, creerEtat(instance, creerPcg32(graine, FLUX.RECHERCHE)).tableDe));
assert.deepStrictEqual(propositions.map(({ plan }) => plan), attendus);
assert.equal(new Set(attendus.map((plan) => JSON.stringify(plan))).size, 4);
});
test('rechercher ne modifie pas la configuration reçue', () => {
const configuration = salleReservee();
rechercher(configuration, { graine: 1, arret: 500, nombre: 1 });
assert.deepStrictEqual(configuration, salleReservee());
});
test('réglage hors de son domaine : RangeError qui le nomme', () => {
const cas = [
[{ graine: -1, arret: 10, nombre: 1 }, 'graine'],
[{ graine: 2 ** 32, arret: 10, nombre: 1 }, 'graine'],
[{ graine: 1.5, arret: 10, nombre: 1 }, 'graine'],
[{ graine: 1, arret: 0, nombre: 1 }, 'arret'],
[{ graine: 1, arret: 2.5, nombre: 1 }, 'arret'],
[{ graine: 1, arret: '10', nombre: 1 }, 'arret'],
[{ graine: 1, arret: 10, nombre: 0 }, 'nombre'],
[{ graine: 1, arret: 10, nombre: 1, historique: 0 }, 'historique'],
];
for (const [reglages, nom] of cas) {
assert.throws(
() => rechercher(demo('petite'), reglages),
(erreur) => erreur instanceof RangeError && erreur.message.startsWith(`${nom} :`),
JSON.stringify(reglages),
);
}
});
});
describe('regenerer : une proposition depuis ses seuls champs (§ 5.7, § 8.9)', () => {
test("pour chaque proposition d'une génération, regenerer rend le même plan à partir de ses trois champs, sans lire ni son plan ni son rang", () => {
// arret 5 000 n'est pas un multiple de l'historique 37 : une descente
// dont le plan dépendrait de son rang dans la génération, par le compte
// des mouvements que la génération a déjà faits, ne se régénérerait pas
// au-delà de la première proposition. Les propositions se régénèrent de
// la dernière à la première, et regenerer ne reçoit que leur graine
// dérivée, leur compte d'arrêt et leur historique, ni leur plan ni leur
// identifiant : chacune sort de ces trois champs, et non de son rang ou
// d'un état que la précédente aurait laissé. salleReservee porte des
// places fantômes et des partiellement fixés.
const reglages = { graine: 5, arret: 5_000, nombre: 3, historique: 37 };
let regenerees = 0;
let conflit = null;
for (const [nom, construire] of [
['salleReservee', salleReservee],
['petite-conflit', () => demo('petite-conflit')],
]) {
const configuration = construire();
const propositions = rechercher(configuration, reglages);
for (const { id, graine, arret, historique, plan } of [...propositions].reverse()) {
assert.deepStrictEqual(
regenerer(configuration, { graine, arret, historique }),
plan,
`${nom}, proposition ${id}`,
);
regenerees += 1;
}
assert.deepStrictEqual(configuration, construire(), nom);
// L'historique décide du plan : sous la longueur par défaut, les mêmes
// graines donnent d'autres plans, ceux que rendrait une régénération
// qui ignorerait ce champ.
assert.notDeepStrictEqual(
rechercher(configuration, { ...reglages, historique: HISTORIQUE_PAR_DEFAUT }).map(({ plan }) => plan),
propositions.map(({ plan }) => plan),
nom,
);
conflit = propositions;
}
assert.equal(regenerees, 6);
// Dans la variante conflit, chaque descente atteint un écart nul,
// bascule, puis relance : la réplique littérale rend les mêmes plans, et
// compte bascules et relances. La régénération rejoue donc aussi le
// placement neuf qu'une relance tire.
const repliques = descentesALaLettre(demo('petite-conflit'), reglages, { bascule: true });
assert.deepStrictEqual(repliques.map(({ plan }) => plan), conflit.map(({ plan }) => plan));
assert.deepEqual(
repliques.map(({ bascules, relances }) => [bascules, relances > 0]),
[
[1, true],
[1, true],
[1, true],
],
);
});
test("le compte d'arrêt décide du plan : à chaque compte de 1 à 32, regenerer rend chaque plan, et un compte au moins change un plan du précédent ; à 100, regenerer rend chaque plan, et la moitié comme le double de ce compte en donnent d'autres", () => {
// Là où la descente ne progresse plus, comme à 5 000 mouvements dans
// l'épreuve précédente, la moitié ou le double du compte rendent les
// mêmes plans, et une régénération qui compterait autrement passerait.
//
// Sur ses premiers mouvements, la descente change souvent de meilleure
// affectation. À chaque compte de 1 à COMPTES_COURTS, regenerer rend le
// plan de chaque proposition à partir de ses trois champs, et l'épreuve
// exige qu'un compte au moins de la boucle change un plan par rapport au
// compte précédent : une régénération qui compterait un mouvement de
// moins rend un autre plan à ce compte, et une qui en compterait un de
// plus, au compte précédent.
//
// À 100 mouvements, la descente progresse encore. Une descente plus
// courte refait, mouvement pour mouvement, le début de celle-ci, et la
// meilleure affectation ne cède qu'à une meilleure : des plans qui
// changent à arret / 2 et à 2 × arret changent à tout compte hors de cet
// intervalle.
const COMPTES_COURTS = 32;
const configuration = demo('petite-conflit');
const reglages = { graine: 5, arret: 100, nombre: 3, historique: 37 };
let precedents = null;
let changements = 0;
for (let compte = 1; compte <= COMPTES_COURTS; compte += 1) {
const courtes = rechercher(configuration, { ...reglages, arret: compte });
for (const { id, graine, arret, historique, plan } of courtes) {
assert.deepStrictEqual(
regenerer(configuration, { graine, arret, historique }),
plan,
`arret ${compte}, proposition ${id}`,
);
}
const plans = courtes.map(({ plan }) => JSON.stringify(plan));
if (precedents !== null && plans.some((plan, i) => plan !== precedents[i])) changements += 1;
precedents = plans;
}
assert.ok(changements > 0, `aucun compte de 2 à ${COMPTES_COURTS} ne change un plan du compte précédent`);
const propositions = rechercher(configuration, reglages);
for (const { id, graine, arret, historique, plan } of [...propositions].reverse()) {
assert.deepStrictEqual(regenerer(configuration, { graine, arret, historique }), plan, `proposition ${id}`);
}
for (const arret of [reglages.arret / 2, 2 * reglages.arret]) {
assert.notDeepStrictEqual(
rechercher(configuration, { ...reglages, arret }).map(({ plan }) => plan),
propositions.map(({ plan }) => plan),
`arret ${arret}`,
);
}
});
test('champ hors de son domaine ou absent : RangeError qui le nomme ; salle trop petite : PLACES_MANQUANTES, comme rechercher', () => {
// Un champ absent ne prend pas la valeur par défaut de rechercher : une
// proposition qui ne porte pas son historique ne se régénère pas sous une
// longueur supposée. Les deux bornes de la graine sont admises.
const champs = { graine: 1, arret: 10, historique: 5 };
for (const graine of [0, 2 ** 32 - 1]) {
assert.equal(regenerer(demo('petite'), { ...champs, graine }).tours.length, 4, `graine ${graine}`);
}
const cas = [
[{ ...champs, graine: -1 }, 'graine'],
[{ ...champs, graine: 2 ** 32 }, 'graine'],
[{ ...champs, graine: 1.5 }, 'graine'],
[{ arret: 10, historique: 5 }, 'graine'],
[{ ...champs, arret: 0 }, 'arret'],
[{ ...champs, arret: '10' }, 'arret'],
[{ ...champs, historique: 0 }, 'historique'],
[{ graine: 1, arret: 10 }, 'historique'],
];
for (const [proposition, nom] of cas) {
assert.throws(
() => regenerer(demo('petite'), proposition),
(erreur) => erreur instanceof RangeError && erreur.message.startsWith(`${nom} :`),
JSON.stringify(proposition),
);
}
const tropPetite = configurationDe({
appartenances: Array(11).fill(null),
tables: [
{ id: 1, capacite: 4 },
{ id: 2, capacite: 4 },
],
tours: 2,
});
assert.throws(() => regenerer(tropPetite, champs), {
name: 'ErreurConfiguration',
code: 'PLACES_MANQUANTES',
details: { placesManquantes: 3 },
});
});
});
describe('rechercher : plans valides (§ 5.2, § 5.9, § 14.12)', () => {
test('chaque proposition des quatre démonstrations tient tous les invariants, réserve interdite', () => {
let examinees = 0;
for (const { cle, construire } of CATALOGUE) {
const configuration = construire();
const instance = normaliser(configuration);
for (const { id, plan } of rechercher(configuration, { graine: 5, arret: 2_000, nombre: 2 })) {
assert.deepEqual(verifierInvariants(instance, plan), [], `${cle}, proposition ${id}`);
examinees += 1;
}
}
assert.equal(examinees, 8);
});
test('ancrés et partiellement fixés sont à leur table à chaque tour fixé', () => {
let examines = 0;
for (const configuration of [salleReservee(), demo('grande')]) {
for (const { id, plan } of rechercher(configuration, { graine: 9, arret: 3_000, nombre: 2 })) {
const releve = reservationsRompues(configuration, plan);
assert.deepEqual(releve.rompues, [], `proposition ${id}`);
examines += releve.examines;
}
}
// Deux propositions de chacune : huit couples dans la salle, 33 ancrés
// sur 4 tours dans la grande démonstration.
assert.equal(examines, 2 * 8 + 2 * 33 * 4);
});
test("l'appartenance d'un ancré compte : son collègue mobile n'est jamais assis à sa table", () => {
// Trois tables de 3, trois tours. 1 et 2 portent l'appartenance X, 1 est
// ancré à la table 1. Chacun peut rencontrer six personnes sans croiser
// personne deux fois, 2 compris, sans jamais s'asseoir à la table 1 :
// trois des quatre classes parallèles du plan affine d'ordre 3, celle où
// 1 et 2 se retrouvent écartée. Éviter la table 1 coûte à 2 au moins un
// retour, que la séparation fait accepter. Une recherche qui ignorerait
// l'appartenance de l'ancré l'y assiérait pour épargner ce retour.
const configuration = configurationDe({
appartenances: ['X', 'X', null, null, null, null, null, null, null],
tables: [
{ id: 1, capacite: 3 },
{ id: 2, capacite: 3 },
{ id: 3, capacite: 3 },
],
tours: 3,
reservations: [tous(1, 1)],
contraintes: { ...SANS_CONTRAINTE, separerAppartenances: true, nouvelleTable: true },
});
const instance = normaliser(configuration);
const propositions = rechercher(configuration, { graine: 4, arret: 20_000, nombre: 3 });
assert.equal(propositions.length, 3);
for (const { id, plan } of propositions) {
const mesures = mesurer(instance, indexerPlan(instance, plan));
assert.equal(mesures.collisionsCumulees, 0, `proposition ${id}`);
assert.deepEqual(
plan.tours.map((listes) => listes[plan.tables.indexOf(1)].includes(2)),
[false, false, false],
`proposition ${id}`,
);
assert.ok(mesures.retoursChoisis[instance.indexDe.get(2)] >= 1, `proposition ${id}`);
}
});
test('salle trop petite : PLACES_MANQUANTES et le nombre exact de places manquantes, aucun plan', () => {
// Douze inscrits dont un exclu, deux tables de 4 et une de 2 : onze
// présents pour dix places. L'exclu ne compte pas, une réservation ne
// change rien au compte (§ 5.9). Le détail se nomme placesManquantes,
// comme dans le diagnostic : « manque » désigne plafond réalisé −
// rencontres (§ 5.5).
const tables = [
{ id: 1, capacite: 4 },
{ id: 2, capacite: 4 },
{ id: 3, capacite: 2 },
];
const appartenances = Array(12).fill(null);
const sansReservation = configurationDe({ appartenances, tables, tours: 2, exclus: [12] });
const avecReservations = configurationDe({
appartenances,
tables,
tours: 2,
exclus: [12],
reservations: [tous(1, 1), auTour(2, 3, 2)],
});
const reglages = { graine: 1, arret: 100, nombre: 2 };
for (const configuration of [sansReservation, avecReservations]) {
assert.throws(() => rechercher(configuration, reglages), {
name: 'ErreurConfiguration',
code: 'PLACES_MANQUANTES',
details: { placesManquantes: 1 },
});
}
const unDePlus = configurationDe({ appartenances: Array(13).fill(null), tables, tours: 2 });
assert.throws(() => rechercher(unDePlus, reglages), {
code: 'PLACES_MANQUANTES',
details: { placesManquantes: 3 },
});
});
});
describe('rechercher : avancement et annulation (§ 5.10)', () => {
test('progression toutes les 1 000 itérations, au rang du mouvement dans nombre × arret', () => {
const etapes = [];
const propositions = rechercher(
demo('petite'),
{ graine: 3, arret: 2_500, nombre: 2 },
{ progression: (fait, total) => etapes.push([fait, total]) },
);
assert.equal(propositions.length, 2);
assert.deepEqual(etapes, [
[1_000, 5_000],
[2_000, 5_000],
[3_000, 5_000],
[4_000, 5_000],
[5_000, 5_000],
]);
});
test("score nul dès le placement : chaque descente s'arrête sans évaluer de mouvement", () => {
// Le signal se lit une fois avant chaque proposition, et progression ne
// reçoit rien : aucun millième mouvement n'est atteint.
const configuration = troisTablesDeDeux();
let lectures = 0;
const signal = {
get aborted() {
lectures += 1;
return false;
},
};
const etapes = [];
const propositions = rechercher(
configuration,
{ graine: 8, arret: 50_000, nombre: 2 },
{ signal, progression: (fait, total) => etapes.push([fait, total]) },
);
assert.equal(propositions.length, 2);
assert.deepEqual(etapes, []);
assert.equal(lectures, 2);
});
test('un signal levé à sa deuxième lecture fait lever ErreurAnnulee, sans rien rendre', () => {
let lectures = 0;
const signal = {
get aborted() {
lectures += 1;
return lectures >= 2;
},
};
const etapes = [];
let rendu = 'rien';
assert.throws(() => {
rendu = rechercher(
demo('petite'),
{ graine: 3, arret: 5_000, nombre: 3 },
{ signal, progression: (fait, total) => etapes.push([fait, total]) },
);
}, ErreurAnnulee);
assert.equal(rendu, 'rien');
assert.equal(lectures, 2);
assert.deepEqual(etapes, [[1_000, 15_000]]);
});
});
describe('état de recherche : placement initial et score incrémental (§ 5.10)', () => {
test("creerEtat : fixés à leur table, les autres table après table dans l'ordre des tables", () => {
// 264 sièges pour 260 personnes : les 32 premières tables se remplissent,
// la 33e reçoit son animateur et les trois derniers mobiles, à chaque
// tour.
const instance = normaliser(demo('grande-sans-exception'));
const etat = creerEtat(instance, creerPcg32(1, FLUX.RECHERCHE));
const plan = planDepuisIndex(instance, etat.tableDe);
assert.equal(plan.tours.length, 4);
for (const listes of plan.tours) {
assert.deepEqual(listes.map((liste) => liste.length), [...Array(32).fill(8), 4]);
}
assert.deepEqual(verifierInvariants(instance, plan), []);
});
test("creerEtat : le placement initial décrit en tête de recherche.js, à la lettre", () => {
// La réplique tire du même flux, dans le même ordre : les deux plans
// coïncident, et les deux générateurs restent au même point.
for (const configuration of [salleReservee(), demo('petite'), demo('grande')]) {
const instance = normaliser(configuration);
const rng = creerPcg32(13, FLUX.RECHERCHE);
const temoin = creerPcg32(13, FLUX.RECHERCHE);
const etat = creerEtat(instance, rng);
assert.deepStrictEqual(etat.tableDe, placementLitteral(instance, temoin));
assert.equal(rng.suivant(), temoin.suivant());
}
});
test('scoreIncremental égale scoreComplet au départ, après 1 et 10 mouvements, et revient au départ une fois défaits', () => {
const instance = normaliser(demo('petite'));
const rng = creerPcg32(42, FLUX.RECHERCHE);
const etat = creerEtat(instance, rng);
const depart = scoreIncremental(etat);
const planDepart = Int32Array.from(etat.tableDe);
assert.equal(depart.length, 6);
assert.deepStrictEqual(depart, scoreComplet(etat));
const appliques = [];
for (let essai = 0; appliques.length < 10 && essai < 1_000; essai += 1) {
const mouvement = proposerEtAppliquer(etat, rng);
if (mouvement === null) continue;
appliques.push(mouvement);
if (appliques.length === 1 || appliques.length === 10) {
assert.deepStrictEqual(scoreIncremental(etat), scoreComplet(etat), `${appliques.length} mouvements`);
}
}
assert.equal(appliques.length, 10);
assert.notDeepStrictEqual(etat.tableDe, planDepart);
for (const mouvement of appliques.reverse()) defaire(etat, mouvement);
assert.deepStrictEqual(etat.tableDe, planDepart);
assert.deepStrictEqual(scoreIncremental(etat), depart);
});
});
describe("rechercher : l'échange avec une place fantôme (paragraphe « Mouvement » de recherche.js)", () => {
test("une personne passe au siège vide d'une autre table : deux tables de 3 pour A, A, B, B, aucune collision sur vingt graines", () => {
// Le placement initial assied trois personnes à la première table et la
// quatrième à la seconde : deux d'une même appartenance s'y retrouvent.
// Un échange entre deux personnes garde trois et une ; seul l'échange
// avec une place fantôme fait passer quelqu'un à la seconde table. À
// deux par table, chacune peut réunir un A et un B, sans collision.
const configuration = configurationDe({
appartenances: ['A', 'A', 'B', 'B'],
tables: [
{ id: 1, capacite: 3 },
{ id: 2, capacite: 3 },
],
tours: 1,
contraintes: { ...SANS_CONTRAINTE, separerAppartenances: true },
});
const instance = normaliser(configuration);
const collisions = [];
for (let graine = 0; graine < 20; graine += 1) {
const [{ plan }] = rechercher(configuration, { graine, arret: 2_000, nombre: 1 });
collisions.push(mesurer(instance, indexerPlan(instance, plan)).collisionsCumulees);
}
assert.deepEqual(collisions, Array(20).fill(0));
});
});
describe('score : composition du n-uplet (en-tête de recherche.js)', () => {
test('scoreIncremental et scoreComplet égalent le n-uplet énuméré, pour aucune contrainte, chacune seule et toutes', () => {
// Score tenu à jour et score recalculé passent par la même composition :
// les comparer entre eux ne dit rien d'elle. L'oracle de ce fichier la
// refait par un autre chemin. Chaque grandeur brute est non nulle dans au
// moins un état de chaque jeu : une composante comptée sous une
// contrainte désactivée, ou omise sous une contrainte active, se voit.
const jeux = [
['aucune', SANS_CONTRAINTE],
...NOMS_CONTRAINTES.map((nom) => [nom, { ...SANS_CONTRAINTE, [nom]: true }]),
['toutes', TOUTES_CONTRAINTES],
];
const MOUVEMENTS = 40;
for (const [nom, contraintes] of jeux) {
const instance = normaliser({ ...salleReservee(), contraintes });
const aPriori = plafondsAPriori(instance);
const rng = creerPcg32(5, FLUX.RECHERCHE);
const etat = creerEtat(instance, rng);
const nonNulles = Array(7).fill(false);
const confronter = (etiquette) => {
const { nUplet, brutes } = scoreOracle(instance, etat.tableDe, aPriori);
assert.deepStrictEqual(scoreComplet(etat), nUplet, `${nom}, ${etiquette} : scoreComplet`);
assert.deepStrictEqual(scoreIncremental(etat), nUplet, `${nom}, ${etiquette} : scoreIncremental`);
for (let k = 0; k < brutes.length; k += 1) if (brutes[k] > 0) nonNulles[k] = true;
};
confronter('placement initial');
let appliques = 0;
for (let essai = 0; appliques < MOUVEMENTS && essai < 100 * MOUVEMENTS; essai += 1) {
if (proposerEtAppliquer(etat, rng) === null) continue;
appliques += 1;
confronter(`${appliques} mouvements`);
}
assert.equal(appliques, MOUVEMENTS, nom);
assert.deepEqual(nonNulles, Array(7).fill(true), `${nom} : grandeurs brutes non nulles`);
}
});
});
describe("rechercher : la descente par acceptation tardive, à la lettre (§ 5.10)", () => {
// Sur la grande démonstration, au moins 24 mobiles restent sous leur
// plafond a priori (§ 15.1) : l'écart maximal ne s'annule jamais, l'ordre
// d'acceptation ne bascule pas, et la relance, qui suit la bascule, ne joue
// pas non plus. rechercher y rend les plans de la réplique littérale.
test('grande démonstration, arret 5 000 : les plans de la réplique', () => {
const reglages = { graine: 17, arret: 5_000, nombre: 2 };
assert.deepStrictEqual(
rechercher(demo('grande'), reglages).map(({ plan }) => plan),
plansLitteraux(demo('grande'), { ...reglages, historique: HISTORIQUE_PAR_DEFAUT }),
);
});
test('grande démonstration sans contrainte, arret 20 000 : les plans de la réplique', () => {
const configuration = () => ({ ...demo('grande'), contraintes: { ...SANS_CONTRAINTE } });
const reglages = { graine: 17, arret: 20_000, nombre: 2 };
assert.deepStrictEqual(
rechercher(configuration(), reglages).map(({ plan }) => plan),
plansLitteraux(configuration(), { ...reglages, historique: HISTORIQUE_PAR_DEFAUT }),
);
});
test("historique d'une case : aucune relance hors de l'ordre des contraintes", () => {
// À historique 1, le score courant reste inchangé sur des plateaux que
// la descente parcourt sans être figée. Une relance dans l'ordre du
// score couperait ces parcours ; la réplique n'en fait aucune.
const reglages = { graine: 17, arret: 5_000, nombre: 2, historique: 1 };
assert.deepStrictEqual(
rechercher(demo('grande'), reglages).map(({ plan }) => plan),
plansLitteraux(demo('grande'), reglages),
);
});
});
describe('rechercher : bascule et relance, à la lettre (en-tête de recherche.js)', () => {
test('petite démonstration en conflit, historique 100, arret 20 000 : les plans de la réplique', () => {
// Chaque descente y atteint un écart maximal nul et bascule ; à
// historique 100, la patience vaut 1 000 itérations, et des relances
// suivent la bascule puis trouvent de meilleures affectations. Les
// comptes de la réplique l'exigent : sans eux, l'épreuve passerait aussi
// sur des descentes qui ne basculent ni ne relancent.
const reglages = { graine: 0, arret: 20_000, nombre: 3, historique: 100 };
const repliques = descentesALaLettre(demo('petite-conflit'), reglages, { bascule: true });
assert.deepStrictEqual(
rechercher(demo('petite-conflit'), reglages).map(({ plan }) => plan),
repliques.map(({ plan }) => plan),
);
assert.deepEqual(
repliques.map(({ bascules, relances, apresRelance }) => [
bascules,
relances > 0,
apresRelance > 0,
]),
[
[1, true, true],
[1, true, true],
[1, true, true],
],
);
});
});

86
src/moteur/types.js Normal file
View file

@ -0,0 +1,86 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Contrat de données du moteur : les formes qu'il reçoit et celles qu'il
// rend. Ce module ne porte que des définitions JSDoc et n'exécute rien ; un
// module cite un type par import('./types.js').Instance. Les valeurs que ces
// formes portent — STATUT, LIBRE, RESERVE, SANS_GROUPE — sont définies et
// exportées par configuration.js, leur seul propriétaire.
/**
* @typedef {Object} Participant
* @property {number} id entier ≥ 1, unique, jamais réutilisé
* @property {string} nom
* @property {string} [prenom]
* @property {string|null} appartenance null = sans appartenance ; l'égalité
* de chaînes fait le groupe (la
* réconciliation est l'affaire du CSV)
* @property {boolean} [exclu] absent = false
*
* @typedef {Object} Table
* @property {number} id entier ≥ 1, unique
* @property {number} numero affiché, ≥ 1
* @property {number} capacite entier ≥ 2, défaut déjà résolu
*
* @typedef {Object} Reservation
* @property {number} participant id
* @property {number} table id
* @property {'tous'|'tour'} portee
* @property {number} [tour] 1..R, requis quand portee === 'tour'
*
* @typedef {Object} Contraintes
* @property {boolean} separerAppartenances
* @property {boolean} nouveauxVoisins
* @property {boolean} nouvelleTable
* @property {boolean} varierAppartenances
*
* @typedef {Object} Configuration
* @property {Participant[]} participants
* @property {Table[]} tables
* @property {number} tours R ≥ 1
* @property {Reservation[]} reservations
* @property {Contraintes} contraintes
*
* @typedef {Object} Plan forme par identifiants (§ 8.9)
* @property {number[]} tables ids de table, dans l'ordre des listes
* @property {number[][][]} tours tours[r][i] = ids assis à tables[i], croissants
* @property {number[][]} reserves reserves[r] = ids assis nulle part au tour r
*
* @typedef {Object} Instance forme indexée, consommée par le moteur
* @property {number} N participants non exclus
* @property {number} T
* @property {number} R
* @property {number[]} ids index → id participant, ordre croissant
* @property {Map<number, number>} indexDe id → index
* @property {Set<number>} exclus ids exclus (pour le vérificateur)
* @property {number[]} idsTables index → id table, ordre de configuration.tables
* @property {Map<number, number>} indexTableDe
* @property {Int32Array} capacite par index de table
* @property {Int32Array} groupe par index participant ; SANS_GROUPE (−1)
* sans appartenance
* @property {string[]} groupes index de groupe → libellé ; ordre de première
* apparition en parcourant les ids croissants
* @property {Int32Array} fixe [p * R + r] → index de table imposé (r à
* partir de 0), LIBRE (−1) si libre
* @property {Uint8Array} statut STATUT.MOBILE (0), STATUT.PARTIELLEMENT_FIXE
* (1), STATUT.ANCRE (2) (§ 4.2)
* @property {Int32Array} ancresParTable a_t
* @property {number} k ancrés
* @property {number} n N − k
* @property {Contraintes} contraintes
*
* Un plan indexé est un Int32Array `tableDe` de longueur N × R :
* tableDe[p * R + r] = index de table, RESERVE (−1) pour la réserve.
* exigerPlanIndexe (configuration.js) en garde la forme.
*
* L'ordre canonique des tableaux par personne est celui de `instance.ids` :
* tout tableau rendu par le moteur et indexé par personne suit cet ordre.
*
* Une personne que le moteur désigne sans identifiant l'est par son index
* canonique, son rang dans `instance.ids`, et `instance.ids[index]` en donne
* l'identifiant. C'est le cas d'une violation de verifierIndicateurs,
* { code, index } : ses trois listes ne portent pas d'identifiant, et la
* traduction revient à l'appelant, qui tient l'instance. Les détails d'une
* ErreurConfiguration et les violations de verifierInvariants désignent au
* contraire les participants par identifiant.
*/

261
src/moteur/verification.js Normal file
View file

@ -0,0 +1,261 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les deux vérificateurs du moteur (§ 14.12), du code de l'application et non
// du code d'épreuve (§ 14.15) : verifierInvariants juge un plan contre
// l'instance, verifierIndicateurs les mesures et les plafonds de chacun
// (§ 12.3). Aucun des deux ne lève sur un défaut du contenu : chaque défaut
// est une violation de la liste rendue, et la liste vide dit que tout tient.
// Aucun ne modifie ce qu'il reçoit. Les formes reçues sont décrites dans
// types.js.
import { LIBRE } from './configuration.js';
/**
* @typedef {{code: string, participant?: number, table?: number, tour?: number,
* detail?: string}} Violation ids, tours numérotés à partir de 1
*
* @typedef {{code: string, index: number}} ViolationIndicateur
* index : rang de la personne dans l'ordre canonique, celui de
* instance.ids
*/
// Index de table d'une liste que plan.tables ne rattache à aucune table de
// l'instance. Aucune case de instance.fixe ne le porte : une réservation ne
// s'honore jamais dans une telle liste.
const SANS_TABLE = -2;
// Index de table de chaque position de plan.tables : celui de l'id déclaré
// quand c'est sa première déclaration et une table de l'instance, SANS_TABLE
// sinon. Ajoute à violations, dans l'ordre de plan.tables, un TABLE_INCONNUE
// 'inconnue' à la première déclaration d'un id étranger à l'instance, et un
// 'doublon' à la deuxième déclaration d'un id de l'instance : un id de table
// déclaré plusieurs fois se signale une seule fois, en 'doublon' s'il
// appartient à l'instance, en 'inconnue' sinon. Ajoute ensuite un 'absente'
// par table de l'instance non déclarée, dans l'ordre de l'instance. Les
// ensembles ne servent qu'à retrouver un id déjà vu.
function rattacherListes(tables, { idsTables, indexTableDe }, violations) {
const tableDeListe = [];
const declarees = new Set();
const doublons = new Set();
for (const id of tables) {
const t = indexTableDe.get(id);
if (!declarees.has(id)) {
declarees.add(id);
if (t === undefined) violations.push({ code: 'TABLE_INCONNUE', table: id, detail: 'inconnue' });
tableDeListe.push(t ?? SANS_TABLE);
continue;
}
if (t !== undefined && !doublons.has(id)) {
doublons.add(id);
violations.push({ code: 'TABLE_INCONNUE', table: id, detail: 'doublon' });
}
tableDeListe.push(SANS_TABLE);
}
for (const id of idsTables) {
if (!declarees.has(id)) violations.push({ code: 'TABLE_INCONNUE', table: id, detail: 'absente' });
}
return tableDeListe;
}
/**
* Violations d'un plan par identifiants (§ 8.9), jugé contre l'instance
* (§ 14.12). Code de l'application : appelé après une génération, après un
* retour arrière, au chargement d'un fichier. Rend la liste des violations,
* vide si le plan tient.
*
* Codes :
* - TOURS : plan.tours ou plan.reserves n'a pas R entrées ;
* - TABLE_INCONNUE : les tables du plan ne reprennent pas celles de
* l'instance. detail précise le cas : 'inconnue', un id de plan.tables
* étranger à l'instance ; 'doublon', un id que plan.tables déclare plus
* d'une fois ; 'absente', une table de l'instance que plan.tables ne
* déclare pas ; ces trois-là portent table. 'listes', un tour qui n'a pas
* une liste par table déclarée, porte tour. L'ordre de plan.tables est
* libre ;
* - INCONNU : un id ni participant ni exclu de l'instance, à une table ou en
* réserve ;
* - EXCLU_PLACE : un participant exclu, à une table ou en réserve ;
* - DOUBLE_PLACE : un participant présent plus d'une fois dans un tour,
* réserve comprise ;
* - NON_ASSIS : un participant absent d'un tour, que la réserve soit permise
* ou non, ou présent seulement en réserve quand reserveAutorisee est faux.
* La réserve est une liste du plan (§ 8.9) : un participant qui ne figure
* ni à une table ni en réserve manque au plan, il n'attend pas ;
* - CAPACITE : une liste plus longue que la capacité de sa table. Le siège
* est la position dans la liste : chaque entrée en occupe un, et le siège
* au-delà de la capacité est hors de sa table ;
* - RESERVATION : un participant que instance.fixe impose à une table à un
* tour, placé ailleurs à ce tour, à une autre table ou en réserve ; table
* nomme la table imposée. La règle ne dépend pas de reserveAutorisee : le
* réservé présent seulement en réserve reçoit RESERVATION que la réserve
* soit permise ou non, précédée de NON_ASSIS quand elle est interdite. Ce
* sont deux règles enfreintes : l'asseoir à une autre table lèverait la
* première et non la seconde. Un participant absent du tour, que le plan
* ne place nulle part, ne relève que de NON_ASSIS.
*
* Une liste se rattache à la table que plan.tables déclare à sa position,
* quand cette déclaration est la première d'un id de l'instance. Une liste
* sans table de l'instance (id étranger, doublon, position au-delà de
* plan.tables) n'entre dans aucune capacité ; ses occupants sont placés à une
* table, donc pas NON_ASSIS, et aucune réservation ne s'y honore. Une table
* déclarée sans liste à un tour est vide à ce tour. Seuls les tours que le
* plan porte en entier, listes et réserve, et dont le numéro ne dépasse pas R
* sont lus : un tour manquant ou incomplet n'est signalé que par TOURS.
*
* Un id étranger à l'instance se signale une fois par tour où il figure, et
* DOUBLE_PLACE ne concerne que les participants. Un id de table déclaré
* plusieurs fois se signale une seule fois : en 'doublon' s'il appartient à
* l'instance, en 'inconnue' sinon.
*
* Les violations se rangent dans l'ordre de lecture : TOURS ; les ids de
* plan.tables dans leur ordre, puis les tables absentes dans l'ordre de
* l'instance ; puis chaque tour lu, par ordre croissant : son nombre de
* listes ; les ids des listes dans l'ordre déclaré puis ceux de la réserve,
* un id étranger à sa première apparition dans le tour et DOUBLE_PLACE à la
* seconde ; les capacités, liste par liste ; enfin les participants par id
* croissant, chacun avec son NON_ASSIS puis sa RESERVATION.
*
* @param {import('./types.js').Instance} instance
* @param {import('./types.js').Plan} plan
* @param {{reserveAutorisee?: boolean}} [options] reserveAutorisee, faux par
* défaut : un participant présent seulement en réserve est NON_ASSIS
* (§ 12.6)
* @returns {Violation[]}
*/
export function verifierInvariants(instance, plan, { reserveAutorisee = false } = {}) {
const { N, R, ids, indexDe, exclus, idsTables, capacite, fixe } = instance;
const violations = [];
if (plan.tours.length !== R || plan.reserves.length !== R) violations.push({ code: 'TOURS' });
const tableDeListe = rattacherListes(plan.tables, instance, violations);
// Par participant, au tour lu : ses présences, réserve comprise ; sa
// présence à une table ; sa présence à la table que fixe lui impose.
const presences = new Int32Array(N);
const aUneTable = new Uint8Array(N);
const aSaTable = new Uint8Array(N);
const lus = Math.min(R, plan.tours.length, plan.reserves.length);
for (let r = 0; r < lus; r += 1) {
const tour = r + 1;
const listes = plan.tours[r];
if (listes.length !== tableDeListe.length) {
violations.push({ code: 'TABLE_INCONNUE', tour, detail: 'listes' });
}
presences.fill(0);
aUneTable.fill(0);
aSaTable.fill(0);
// Compte une présence de id à ce tour et rend son index ; −1 pour un id
// étranger à l'instance, signalé à sa première apparition dans le tour.
const etrangers = new Set();
const compter = (id) => {
const p = indexDe.get(id);
if (p === undefined) {
if (!etrangers.has(id)) {
etrangers.add(id);
violations.push({ code: exclus.has(id) ? 'EXCLU_PLACE' : 'INCONNU', participant: id, tour });
}
return -1;
}
presences[p] += 1;
if (presences[p] === 2) violations.push({ code: 'DOUBLE_PLACE', participant: id, tour });
return p;
};
for (let i = 0; i < listes.length; i += 1) {
const t = i < tableDeListe.length ? tableDeListe[i] : SANS_TABLE;
for (const id of listes[i]) {
const p = compter(id);
if (p < 0) continue;
aUneTable[p] = 1;
if (fixe[p * R + r] === t) aSaTable[p] = 1;
}
}
for (const id of plan.reserves[r]) compter(id);
for (let i = 0; i < listes.length && i < tableDeListe.length; i += 1) {
const t = tableDeListe[i];
if (t !== SANS_TABLE && listes[i].length > capacite[t]) {
violations.push({ code: 'CAPACITE', table: idsTables[t], tour });
}
}
for (let p = 0; p < N; p += 1) {
if (!aUneTable[p] && (presences[p] === 0 || !reserveAutorisee)) {
violations.push({ code: 'NON_ASSIS', participant: ids[p], tour });
}
const imposee = fixe[p * R + r];
if (imposee !== LIBRE && presences[p] > 0 && !aSaTable[p]) {
violations.push({ code: 'RESERVATION', participant: ids[p], table: idsTables[imposee], tour });
}
}
}
return violations;
}
// Vrai quand gauche ≥ droite et que les deux sont des nombres : NaN, ou une
// valeur d'un autre type, fait tomber l'inégalité au lieu d'y être convertie.
const tient = (gauche, droite) =>
typeof gauche === 'number' && typeof droite === 'number' && gauche >= droite;
// Lève TypeError quand valeurs n'a pas de longueur, n'étant pas une liste,
// et RangeError quand elle n'a pas N cases. attendu nomme ce que le paramètre
// nom admet.
function exigerLongueur(valeurs, N, nom, attendu) {
if (typeof valeurs?.length !== 'number') throw new TypeError(`${nom} : ${attendu} attendue`);
if (valeurs.length !== N) {
throw new RangeError(`${nom} : ${valeurs.length} cases, ${N} attendues comme mesures.rencontres`);
}
}
/**
* Garde du § 12.3 : éprouve, pour chacun, la ligne de décomposition du
* § 12.10.5, N − 1 ≥ plafond a priori ≥ plafond réalisé ≥ rencontres, une
* inégalité entre deux termes voisins à la fois ; ensemble, elles ordonnent
* toute la ligne. Une violation désigne un calcul faux, sur lequel le profil
* ne se dessine pas.
*
* Les trois listes suivent l'ordre canonique, celui de instance.ids, et N est
* la longueur de mesures.rencontres. plafondsAPriori vaut null quand le
* plafond a priori est inconnu (§ 12.6) : la ligne perd ce terme, et seule la
* chaîne N − 1 ≥ réalisé ≥ rencontres est éprouvée. Une inégalité ne tient
* qu'entre deux nombres : NaN, ou une valeur d'un autre type, la fait tomber
* au lieu d'y être convertie.
*
* Codes : PLAFOND_A_PRIORI (a priori > N − 1), ORDRE_PLAFONDS (a priori <
* réalisé), PLAFOND_REALISE (réalisé > N − 1, a priori inconnu), DEPASSEMENT
* (rencontres > réalisé). La fonction ne reçoit pas les identifiants : une
* violation désigne la personne par index, son rang dans l'ordre canonique,
* que instance.ids[index] traduit. Les violations se rangent par index
* croissant, puis de gauche à droite sur la ligne.
*
* Lève RangeError quand une liste de plafonds n'a pas N cases : elle décrit
* une autre population, et l'écart est une faute de code. Lève TypeError
* quand plafondsRealises n'est pas une liste, ou plafondsAPriori ni une liste
* ni null : seul null dit l'a priori inconnu. undefined, la valeur d'un champ
* manquant, ne passe pas pour lui : il ôterait deux inégalités à la garde
* sans rien signaler.
*
* @param {{rencontres: ArrayLike<number>}} mesures les mesures d'un plan ;
* seule la liste rencontres est lue
* @param {ArrayLike<number>|null} plafondsAPriori
* @param {ArrayLike<number>} plafondsRealises
* @returns {ViolationIndicateur[]}
*/
export function verifierIndicateurs(mesures, plafondsAPriori, plafondsRealises) {
const { rencontres } = mesures;
const N = rencontres.length;
exigerLongueur(plafondsRealises, N, 'plafondsRealises', 'liste');
if (plafondsAPriori !== null) exigerLongueur(plafondsAPriori, N, 'plafondsAPriori', 'liste ou null');
const violations = [];
for (let p = 0; p < N; p += 1) {
const realise = plafondsRealises[p];
if (plafondsAPriori === null) {
if (!tient(N - 1, realise)) violations.push({ code: 'PLAFOND_REALISE', index: p });
} else {
const aPriori = plafondsAPriori[p];
if (!tient(N - 1, aPriori)) violations.push({ code: 'PLAFOND_A_PRIORI', index: p });
if (!tient(aPriori, realise)) violations.push({ code: 'ORDRE_PLAFONDS', index: p });
}
if (!tient(realise, rencontres[p])) violations.push({ code: 'DEPASSEMENT', index: p });
}
return violations;
}

View file

@ -0,0 +1,829 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des deux vérificateurs du moteur (§ 14.12), par mutation : chaque
// épreuve part d'un état qui tient, et chaque mutation y produit la violation
// annoncée et aucune autre. Un vérificateur qui rend toujours la liste vide
// échoue à chacune.
//
// verifierInvariants part du plan parfait de la petite démonstration
// (§ 15.3), recopié ci-dessous. La configuration ajoute à la démonstration ce
// que deux codes demandent : un participant exclu, 13, dont la réservation
// est suspendue (§ 4.4), et une réservation de tour désigné que le plan
// honore, 6 à la table de numéro 1 au tour 2. Les épreuves désignent
// participants et tables par leur numéro ; participant() et table() en
// donnent l'identifiant. Les participants de numéro 1 à 13 portent les
// identifiants 107 à 191, de 7 en 7 : ni contigus, ni égaux à leur index plus
// un. Les tables de numéro 1 à 4 portent les identifiants 21 à 24, que
// l'instance range dans l'ordre 23, 21, 24, 22 et le plan dans l'ordre des
// numéros. Confondre identifiant, numéro, index et position, d'un participant
// comme d'une table, fait échouer une épreuve au lieu de passer inaperçu.
//
// verifierIndicateurs part des chiffres de ce même plan : N = 12, et 8 pour
// chacun en plafond a priori, en plafond réalisé et en rencontres (§ 15.3).
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { indexerPlan, normaliser } from './configuration.js';
import { ErreurConfiguration } from './erreurs.js';
import { verifierIndicateurs, verifierInvariants } from './verification.js';
const SANS_CONTRAINTE = {
separerAppartenances: false,
nouveauxVoisins: false,
nouvelleTable: false,
varierAppartenances: false,
};
// Identifiant du participant de numéro donné.
const participant = (numero) => 100 + 7 * numero;
// Identifiants des participants de numéros donnés, dans l'ordre donné.
const participants = (...numeros) => numeros.map(participant);
// Identifiant de la table de numéro donné.
const table = (numero) => 20 + numero;
// Identifiant qui n'est celui d'aucun participant, exclu compris, ni d'aucune
// table.
const ETRANGER = 99;
// Numéros des membres de chaque appartenance de la petite démonstration
// (§ 15.3).
const MEMBRES = [
['A', [1, 5, 8, 12]],
['B', [2, 4, 9, 11]],
['C', [3, 6, 7, 10]],
];
const appartenanceDe = (numero) => MEMBRES.find(([, numeros]) => numeros.includes(numero))[0];
const RESERVATION_HONOREE = { participant: participant(6), table: table(1), portee: 'tour', tour: 2 };
const RESERVATION_SUSPENDUE = { participant: participant(13), table: table(2), portee: 'tous' };
function configuration(reservations = [RESERVATION_HONOREE, RESERVATION_SUSPENDUE]) {
const membres = Array.from({ length: 12 }, (_, i) => ({
id: participant(i + 1),
nom: `P${i + 1}`,
appartenance: appartenanceDe(i + 1),
}));
return {
participants: [...membres, { id: participant(13), nom: 'P13', appartenance: null, exclu: true }],
tables: [3, 1, 4, 2].map((numero) => ({ id: table(numero), numero, capacite: 3 })),
tours: 4,
reservations,
contraintes: SANS_CONTRAINTE,
};
}
// Le plan parfait de la petite démonstration (§ 15.3), écrit en numéros : à
// chaque tour, les tables de numéro 1 à 4, chacune réunissant un membre de
// chaque appartenance ; personne en réserve. Gelé, comme chaque plan que les
// épreuves passent à verifierInvariants : une écriture du vérificateur y lève
// TypeError, quelle que soit la branche qui la fait.
const PLAN_PARFAIT = gelerPlan({
tables: [table(1), table(2), table(3), table(4)],
tours: [
[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]],
[[6, 9, 12], [3, 8, 11], [2, 5, 10], [1, 4, 7]],
[[4, 8, 10], [2, 7, 12], [1, 6, 11], [3, 5, 9]],
[[5, 7, 11], [1, 9, 10], [3, 4, 12], [2, 6, 8]],
].map((listes) => listes.map((numeros) => participants(...numeros))),
reserves: [[], [], [], []],
});
const INSTANCE = normaliser(configuration());
// Copie du plan parfait, mutée par modifier puis gelée.
function planMute(modifier) {
const plan = structuredClone(PLAN_PARFAIT);
modifier(plan);
return gelerPlan(plan);
}
// Violations du plan parfait, une fois que modifier a muté une copie.
function apresMutation(modifier, options) {
return verifierInvariants(INSTANCE, planMute(modifier), options);
}
// Retire id de la liste de table qui le porte.
function retirer(listes, id) {
for (const liste of listes) {
if (liste.includes(id)) liste.splice(liste.indexOf(id), 1);
}
}
// Chaque cas [libellé, calcul, attendu] dont le résultat diffère de
// l'attendu, en une ligne lisible ; une exception est un écart.
function ecarts(cas) {
assert.ok(cas.length > 0, 'aucun cas examiné');
return cas.flatMap(([libelle, calcul, attendu]) => {
let obtenu;
try {
obtenu = calcul();
} catch (erreur) {
return [`${libelle} : ${erreur?.name} ${erreur?.message}`];
}
try {
assert.deepEqual(obtenu, attendu);
return [];
} catch {
return [`${libelle} : ${JSON.stringify(obtenu)}`];
}
});
}
// Gèle un plan et chacune de ses listes, puis le rend : une écriture faite
// depuis un module ES, toujours en mode strict, y lève TypeError.
function gelerPlan(plan) {
const geler = (valeur) => {
if (Array.isArray(valeur)) valeur.forEach(geler);
return Object.freeze(valeur);
};
geler(plan.tables);
geler(plan.tours);
geler(plan.reserves);
return Object.freeze(plan);
}
const CODES = [
'TOURS',
'TABLE_INCONNUE',
'INCONNU',
'EXCLU_PLACE',
'DOUBLE_PLACE',
'NON_ASSIS',
'CAPACITE',
'RESERVATION',
];
// [libellé, mutation du plan parfait, options, violations attendues]. Chaque
// code a au moins une mutation qui le produit seul.
const MUTATIONS = [
['un tour de moins', (plan) => {
plan.tours.pop();
plan.reserves.pop();
}, undefined, [{ code: 'TOURS' }]],
['un tour de trop', (plan) => {
plan.tours.push(structuredClone(plan.tours[0]));
plan.reserves.push([]);
}, undefined, [{ code: 'TOURS' }]],
['une réserve de moins', (plan) => {
plan.reserves.pop();
}, undefined, [{ code: 'TOURS' }]],
['une table inconnue, vide à chaque tour', (plan) => {
plan.tables.push(ETRANGER);
plan.tours.forEach((listes) => listes.push([]));
}, undefined, [{ code: 'TABLE_INCONNUE', table: ETRANGER, detail: 'inconnue' }]],
['une table inconnue déclarée deux fois, vide à chaque tour', (plan) => {
plan.tables.push(ETRANGER, ETRANGER);
plan.tours.forEach((listes) => listes.push([], []));
}, undefined, [{ code: 'TABLE_INCONNUE', table: ETRANGER, detail: 'inconnue' }]],
['une table déclarée deux fois, vide à sa seconde position', (plan) => {
plan.tables.push(table(3));
plan.tours.forEach((listes) => listes.push([]));
}, undefined, [{ code: 'TABLE_INCONNUE', table: table(3), detail: 'doublon' }]],
// La table de numéro 1 déclarée une seconde fois. Au tour 1, sa seconde
// liste reçoit ses trois convives et 4 : quatre entrées, qu'aucune capacité
// ne borne. Au tour 2, elle reçoit 6, réservé à la table de numéro 1 : la
// réservation ne s'y honore pas.
['une table déclarée deux fois, sa seconde liste peuplée', (plan) => {
plan.tables.push(table(1));
plan.tours.forEach((listes) => listes.push([]));
retirer(plan.tours[0], participant(4));
plan.tours[0][4] = [...plan.tours[0][0], participant(4)];
plan.tours[0][0] = [];
retirer(plan.tours[1], participant(6));
plan.tours[1][4].push(participant(6));
}, undefined, [
{ code: 'TABLE_INCONNUE', table: table(1), detail: 'doublon' },
{ code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 },
]],
['une table déclarée à la place d’une autre', (plan) => {
plan.tables[1] = table(1);
}, undefined, [
{ code: 'TABLE_INCONNUE', table: table(1), detail: 'doublon' },
{ code: 'TABLE_INCONNUE', table: table(2), detail: 'absente' },
]],
['une table non déclarée, ses occupants en réserve permise', (plan) => {
plan.tables.pop();
plan.tours.forEach((listes, r) => plan.reserves[r].push(...listes.pop()));
}, { reserveAutorisee: true }, [{ code: 'TABLE_INCONNUE', table: table(4), detail: 'absente' }]],
['une liste de trop au tour 3', (plan) => {
plan.tours[2].push([]);
}, undefined, [{ code: 'TABLE_INCONNUE', tour: 3, detail: 'listes' }]],
['une liste de moins au tour 3, ses occupants en réserve permise', (plan) => {
plan.reserves[2].push(...plan.tours[2].pop());
}, { reserveAutorisee: true }, [{ code: 'TABLE_INCONNUE', tour: 3, detail: 'listes' }]],
// Le nombre de listes d'un tour se lit avant ses présences, dans l'ordre où
// indexerPlan refuse : l'épreuve croisée compare les deux sur ce plan.
['une liste de trop au tour 2, un inconnu en réserve au même tour', (plan) => {
plan.tours[1].push([]);
plan.reserves[1].push(ETRANGER);
}, undefined, [
{ code: 'TABLE_INCONNUE', tour: 2, detail: 'listes' },
{ code: 'INCONNU', participant: ETRANGER, tour: 2 },
]],
['un inconnu en réserve', (plan) => {
plan.reserves[1].push(ETRANGER);
}, undefined, [{ code: 'INCONNU', participant: ETRANGER, tour: 2 }]],
['l’exclu en réserve', (plan) => {
plan.reserves[0].push(participant(13));
}, undefined, [{ code: 'EXCLU_PLACE', participant: participant(13), tour: 1 }]],
['l’exclu à la place de 11, passé en réserve permise', (plan) => {
plan.tours[3][0] = participants(5, 7, 13);
plan.reserves[3].push(participant(11));
}, { reserveAutorisee: true }, [{ code: 'EXCLU_PLACE', participant: participant(13), tour: 4 }]],
// 6, assis à la table de numéro 1 au tour 2, occupe aussi la place de 11 à
// la table de numéro 2 ; 11 attend en réserve.
['à deux tables au tour 2', (plan) => {
plan.tours[1][1] = participants(3, 6, 8);
plan.reserves[1].push(participant(11));
}, { reserveAutorisee: true }, [{ code: 'DOUBLE_PLACE', participant: participant(6), tour: 2 }]],
['deux sièges à la même table', (plan) => {
plan.tours[1][0] = participants(6, 6, 12);
plan.reserves[1].push(participant(9));
}, { reserveAutorisee: true }, [{ code: 'DOUBLE_PLACE', participant: participant(6), tour: 2 }]],
['assis et en réserve', (plan) => {
plan.reserves[1].push(participant(6));
}, undefined, [{ code: 'DOUBLE_PLACE', participant: participant(6), tour: 2 }]],
['absent d’un tour', (plan) => {
retirer(plan.tours[2], participant(7));
}, undefined, [{ code: 'NON_ASSIS', participant: participant(7), tour: 3 }]],
['absent d’un tour, la réserve permise', (plan) => {
retirer(plan.tours[2], participant(7));
}, { reserveAutorisee: true }, [{ code: 'NON_ASSIS', participant: participant(7), tour: 3 }]],
['en réserve, la réserve interdite par défaut', (plan) => {
retirer(plan.tours[2], participant(7));
plan.reserves[2].push(participant(7));
}, undefined, [{ code: 'NON_ASSIS', participant: participant(7), tour: 3 }]],
['en réserve, la réserve interdite nommément', (plan) => {
retirer(plan.tours[2], participant(7));
plan.reserves[2].push(participant(7));
}, { reserveAutorisee: false }, [{ code: 'NON_ASSIS', participant: participant(7), tour: 3 }]],
['quatre à la table de numéro 2, de trois places', (plan) => {
retirer(plan.tours[3], participant(11));
plan.tours[3][1].push(participant(11));
}, undefined, [{ code: 'CAPACITE', table: table(2), tour: 4 }]],
// 6, réservé à la table de numéro 1 au tour 2, échange sa place avec 3,
// assis à la table de numéro 2.
['le réservé échangé avec un convive d’une autre table', (plan) => {
plan.tours[1][0] = participants(3, 9, 12);
plan.tours[1][1] = participants(6, 8, 11);
}, undefined, [{ code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 }]],
['le réservé en réserve permise', (plan) => {
retirer(plan.tours[1], participant(6));
plan.reserves[1].push(participant(6));
}, { reserveAutorisee: true }, [{ code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 }]],
// La même réserve, interdite : 6 n'est pas assis, et sa réservation n'est
// pas honorée. Deux règles enfreintes : NON_ASSIS, puis RESERVATION.
['le réservé en réserve, la réserve interdite', (plan) => {
retirer(plan.tours[1], participant(6));
plan.reserves[1].push(participant(6));
}, undefined, [
{ code: 'NON_ASSIS', participant: participant(6), tour: 2 },
{ code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 },
]],
];
describe('verifierInvariants : le plan parfait de la petite démonstration (§ 15.3)', () => {
test('ne viole rien, la réserve interdite ou permise', () => {
assert.deepEqual(verifierInvariants(INSTANCE, PLAN_PARFAIT), []);
assert.deepEqual(verifierInvariants(INSTANCE, PLAN_PARFAIT, { reserveAutorisee: true }), []);
});
test('ne viole rien, ses tables déclarées dans un autre ordre et ses listes dans le désordre', () => {
// L'ordre de l'instance, 23, 21, 24, 22 : chaque liste suit sa table.
const ordre = [2, 0, 3, 1];
const permute = gelerPlan({
tables: ordre.map((i) => PLAN_PARFAIT.tables[i]),
tours: PLAN_PARFAIT.tours.map((listes) => ordre.map((i) => [...listes[i]].reverse())),
reserves: [[], [], [], []],
});
assert.deepEqual(verifierInvariants(INSTANCE, permute), []);
});
});
describe('verifierInvariants : chaque mutation, sa violation et aucune autre (§ 14.12)', () => {
test('chaque code a sa mutation, et chaque mutation produit exactement ses violations', () => {
const seuls = new Set(MUTATIONS
.filter(([, , , attendu]) => attendu.length === 1)
.map(([, , , [{ code }]]) => code));
assert.deepEqual(CODES.filter((code) => !seuls.has(code)), [], 'code sans mutation qui le produise seul');
const cas = MUTATIONS.map(([libelle, modifier, options, attendu]) => [
libelle,
() => apresMutation(modifier, options),
attendu,
]);
assert.deepEqual(ecarts(cas), []);
});
test('NON_ASSIS : chaque participant retiré de chaque tour, et lui seul, la réserve interdite ou permise', () => {
// La réserve est une liste du plan (§ 8.9) : un participant qui n'y
// figure pas plus qu'à une table n'attend pas, il manque.
const cas = [];
for (const reserveAutorisee of [false, true]) {
for (let tour = 1; tour <= 4; tour += 1) {
for (let numero = 1; numero <= 12; numero += 1) {
const id = participant(numero);
cas.push([
`${numero} retiré du tour ${tour}, réserve ${reserveAutorisee ? 'permise' : 'interdite'}`,
() => apresMutation((plan) => retirer(plan.tours[tour - 1], id), { reserveAutorisee }),
[{ code: 'NON_ASSIS', participant: id, tour }],
]);
}
}
}
assert.equal(cas.length, 96);
assert.deepEqual(ecarts(cas), []);
});
test('DOUBLE_PLACE : chaque participant ajouté à la réserve de chaque tour, et lui seul', () => {
const cas = [];
for (let tour = 1; tour <= 4; tour += 1) {
for (let numero = 1; numero <= 12; numero += 1) {
const id = participant(numero);
cas.push([
`${numero} en réserve au tour ${tour}`,
() => apresMutation((plan) => plan.reserves[tour - 1].push(id)),
[{ code: 'DOUBLE_PLACE', participant: id, tour }],
]);
}
}
assert.equal(cas.length, 48);
assert.deepEqual(ecarts(cas), []);
});
test('CAPACITE : chaque table de chaque tour, nommée par son identifiant', () => {
// Le dernier convive de la table suivante rejoint la table examinée. Le
// dernier n'est jamais 6 au tour 2 : la réservation reste honorée.
const cas = [];
for (let tour = 1; tour <= 4; tour += 1) {
for (let i = 0; i < 4; i += 1) {
cas.push([
`tour ${tour}, table de numéro ${i + 1}`,
() => apresMutation((plan) => {
const listes = plan.tours[tour - 1];
listes[i].push(listes[(i + 1) % 4].pop());
}),
[{ code: 'CAPACITE', table: table(i + 1), tour }],
]);
}
}
assert.equal(cas.length, 16);
assert.deepEqual(ecarts(cas), []);
});
test('RESERVATION : chaque participant, réservé à chaque tour, à sa table puis à la suivante', () => {
// Une seule réservation de tour désigné par configuration : à la table
// où le plan parfait assoit la personne, elle est honorée ; à la table
// de numéro suivant, elle ne l'est pas.
const cas = [];
for (let tour = 1; tour <= 4; tour += 1) {
for (let numero = 1; numero <= 12; numero += 1) {
const id = participant(numero);
const sienne = PLAN_PARFAIT.tours[tour - 1].findIndex((liste) => liste.includes(id)) + 1;
const ailleurs = (sienne % 4) + 1;
const reserve = (numeroTable) => normaliser(configuration([
{ participant: id, table: table(numeroTable), portee: 'tour', tour },
]));
cas.push([
`${numero} réservé à sa table au tour ${tour}`,
() => verifierInvariants(reserve(sienne), PLAN_PARFAIT),
[],
]);
cas.push([
`${numero} réservé à une autre table au tour ${tour}`,
() => verifierInvariants(reserve(ailleurs), PLAN_PARFAIT),
[{ code: 'RESERVATION', participant: id, table: table(ailleurs), tour }],
]);
}
}
assert.equal(cas.length, 96);
assert.deepEqual(ecarts(cas), []);
});
});
describe('verifierInvariants : ce que chaque code couvre', () => {
test('une personne en réserve n’est signalée que si la réserve est interdite, une personne absente l’est toujours (point d’attention 1)', () => {
const enReserve = (plan) => {
retirer(plan.tours[2], participant(7));
plan.reserves[2].push(participant(7));
};
const absente = (plan) => retirer(plan.tours[2], participant(7));
const nonAssis = [{ code: 'NON_ASSIS', participant: participant(7), tour: 3 }];
assert.deepEqual(apresMutation(enReserve), nonAssis);
assert.deepEqual(apresMutation(enReserve, { reserveAutorisee: true }), []);
assert.deepEqual(apresMutation(absente), nonAssis);
assert.deepEqual(apresMutation(absente, { reserveAutorisee: true }), nonAssis);
});
test('une réservation « tous les tours » se juge à chacun des tours', () => {
// 1 est réservé à la table de numéro 1 à chaque tour ; le plan parfait
// l'y assoit au tour 1, puis aux tables de numéro 4, 3 et 2.
const instance = normaliser(configuration([
RESERVATION_HONOREE,
{ participant: participant(1), table: table(1), portee: 'tous' },
]));
assert.deepEqual(verifierInvariants(instance, PLAN_PARFAIT), [2, 3, 4].map((tour) => (
{ code: 'RESERVATION', participant: participant(1), table: table(1), tour })));
});
test('CAPACITE se juge contre la capacité de la table que la liste désigne', () => {
// Deux tables de capacités différentes, que le plan déclare dans l'ordre
// inverse de l'instance.
const instance = normaliser({
participants: [1, 2, 3, 4, 5, 6].map((id) => ({ id, nom: `P${id}`, appartenance: null })),
tables: [{ id: 7, numero: 1, capacite: 2 }, { id: 5, numero: 2, capacite: 4 }],
tours: 1,
reservations: [],
contraintes: SANS_CONTRAINTE,
});
const plan = (listes) => gelerPlan({ tables: [5, 7], tours: [listes], reserves: [[]] });
assert.deepEqual(verifierInvariants(instance, plan([[1, 2, 3, 4], [5, 6]])), []);
assert.deepEqual(
verifierInvariants(instance, plan([[1, 2, 3], [4, 5, 6]])),
[{ code: 'CAPACITE', table: 7, tour: 1 }],
);
});
test('une table inconnue : ses occupants sont placés, hors capacité, sans réservation honorée', () => {
// La table de numéro 1 déclarée sous un identifiant étranger : il est
// inconnu, 21 absente. Ses occupants restent placés ; au tour 1, elle en
// reçoit quatre ; au tour 2, 6 y est assis alors que sa réservation le
// fixe à la table 21.
const obtenu = apresMutation((plan) => {
plan.tables[0] = ETRANGER;
retirer(plan.tours[0], participant(4));
plan.tours[0][0].push(participant(4));
});
assert.deepEqual(obtenu, [
{ code: 'TABLE_INCONNUE', table: ETRANGER, detail: 'inconnue' },
{ code: 'TABLE_INCONNUE', table: table(1), detail: 'absente' },
{ code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 },
]);
});
test('les occupants d’une liste au-delà des tables déclarées sont placés', () => {
const obtenu = apresMutation((plan) => {
retirer(plan.tours[2], participant(7));
plan.tours[2].push([participant(7)]);
});
assert.deepEqual(obtenu, [{ code: 'TABLE_INCONNUE', tour: 3, detail: 'listes' }]);
});
test('CAPACITE compte chaque entrée de la liste, étrangère ou répétée', () => {
// Le siège est la position dans la liste : un id étranger ou répété
// occupe un siège comme un autre.
const obtenu = apresMutation((plan) => {
plan.tours[0][0].push(ETRANGER);
plan.tours[1][0].push(participant(12));
});
assert.deepEqual(obtenu, [
{ code: 'INCONNU', participant: ETRANGER, tour: 1 },
{ code: 'CAPACITE', table: table(1), tour: 1 },
{ code: 'DOUBLE_PLACE', participant: participant(12), tour: 2 },
{ code: 'CAPACITE', table: table(1), tour: 2 },
]);
});
test('un participant présent trois fois dans un tour se signale une fois', () => {
// 6 occupe aussi la place de 11 à la table de numéro 2 et figure en
// réserve ; 11 attend en réserve.
const obtenu = apresMutation((plan) => {
plan.tours[1][1] = participants(3, 6, 8);
plan.reserves[1].push(participant(6), participant(11));
}, { reserveAutorisee: true });
assert.deepEqual(obtenu, [{ code: 'DOUBLE_PLACE', participant: participant(6), tour: 2 }]);
});
test('un identifiant étranger à l’instance se signale une fois par tour où il figure', () => {
const obtenu = apresMutation((plan) => {
plan.reserves[0].push(ETRANGER, participant(13), ETRANGER);
plan.tours[0][0].push(participant(13));
retirer(plan.tours[0], participant(3));
plan.reserves[0].push(participant(3));
plan.reserves[2].push(ETRANGER);
}, { reserveAutorisee: true });
assert.deepEqual(obtenu, [
{ code: 'EXCLU_PLACE', participant: participant(13), tour: 1 },
{ code: 'INCONNU', participant: ETRANGER, tour: 1 },
{ code: 'INCONNU', participant: ETRANGER, tour: 3 },
]);
});
test('une table déclarée trois fois se signale une fois', () => {
const obtenu = apresMutation((plan) => {
plan.tables.push(table(2), table(2));
plan.tours.forEach((listes) => listes.push([], []));
});
assert.deepEqual(obtenu, [{ code: 'TABLE_INCONNUE', table: table(2), detail: 'doublon' }]);
});
});
describe('verifierInvariants : la liste rendue', () => {
test('les violations se rangent dans l’ordre de lecture du plan', () => {
// Un tour de moins : seuls les tours 1 à 3 sont lus. Une table inconnue,
// vide. Au tour 2 : une liste de trop, 6 échangé avec 3, 5 retiré, 2
// passé de la table de numéro 3 à la table de numéro 4, et en réserve un
// étranger, l'exclu et 12. Au tour 3, une liste de trop encore. Le nombre
// de listes ouvre les violations de son tour : celle du tour 2 précède ses
// présences, celle du tour 3 suit tout le tour 2.
const obtenu = apresMutation((plan) => {
plan.tours.pop();
plan.reserves.pop();
plan.tables.push(ETRANGER);
plan.tours.forEach((listes) => listes.push([]));
plan.tours[1][0] = participants(3, 9, 12);
plan.tours[1][1] = participants(6, 8, 11);
plan.tours[1][2] = participants(10);
plan.tours[1][3] = participants(1, 2, 4, 7);
plan.reserves[1].push(ETRANGER, participant(13), participant(12));
plan.tours[1].push([]);
plan.tours[2].push([]);
});
assert.deepEqual(obtenu, [
{ code: 'TOURS' },
{ code: 'TABLE_INCONNUE', table: ETRANGER, detail: 'inconnue' },
{ code: 'TABLE_INCONNUE', tour: 2, detail: 'listes' },
{ code: 'INCONNU', participant: ETRANGER, tour: 2 },
{ code: 'EXCLU_PLACE', participant: participant(13), tour: 2 },
{ code: 'DOUBLE_PLACE', participant: participant(12), tour: 2 },
{ code: 'CAPACITE', table: table(4), tour: 2 },
{ code: 'NON_ASSIS', participant: participant(5), tour: 2 },
{ code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 },
{ code: 'TABLE_INCONNUE', tour: 3, detail: 'listes' },
]);
});
test('les tables absentes se rangent dans l’ordre de l’instance', () => {
// Les tables de numéro 2 et 3 retirées du plan, leurs occupants en
// réserve permise. L'instance range 23 avant 22, à l'inverse du plan et
// des numéros.
const obtenu = apresMutation((plan) => {
plan.tables.splice(1, 2);
plan.tours.forEach((listes, r) => plan.reserves[r].push(...listes.splice(1, 2).flat()));
}, { reserveAutorisee: true });
assert.deepEqual(obtenu, [
{ code: 'TABLE_INCONNUE', table: table(3), detail: 'absente' },
{ code: 'TABLE_INCONNUE', table: table(2), detail: 'absente' },
]);
});
test('NON_ASSIS et RESERVATION se rangent participant par participant', () => {
// Au tour 2, 6 échangé avec 3, et 7 retiré : la violation de 6 précède
// celle de 7, quel que soit son code.
const obtenu = apresMutation((plan) => {
plan.tours[1][0] = participants(3, 9, 12);
plan.tours[1][1] = participants(6, 8, 11);
retirer(plan.tours[1], participant(7));
});
assert.deepEqual(obtenu, [
{ code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 },
{ code: 'NON_ASSIS', participant: participant(7), tour: 2 },
]);
});
test('ne modifie ni le plan ni l’instance, sous chaque mutation', () => {
// planMute rend un plan gelé. L'instance est bâtie pour chaque cas et
// copiée avant tout appel : la copie d'une instance déjà passée au
// vérificateur porterait ce qu'il y aurait écrit, et la comparaison ne le
// verrait pas.
const cas = MUTATIONS.map(([libelle, modifier, options]) => [libelle, () => {
const instance = normaliser(configuration());
const copie = structuredClone(instance);
verifierInvariants(instance, planMute(modifier), options);
assert.deepEqual(instance, copie, 'instance modifiée');
return 'intacte';
}, 'intacte']);
assert.deepEqual(ecarts(cas), []);
});
});
describe('verifierInvariants et indexerPlan : une même lecture du plan (§ 8.9)', () => {
// Code sous lequel indexerPlan refuse chaque violation de forme, désignée
// par son code, suivi de son detail quand elle en porte un. CAPACITE et
// RESERVATION n'en ont pas : indexerPlan n'examine ni les capacités ni les
// réservations.
const cle = ({ code, detail }) => (detail === undefined ? code : `${code} ${detail}`);
const REFUS = new Map([
['TOURS', 'PLAN_TOURS'],
['TABLE_INCONNUE inconnue', 'PLAN_TABLE_INCONNUE'],
['TABLE_INCONNUE doublon', 'PLAN_TABLE_DOUBLON'],
['TABLE_INCONNUE absente', 'PLAN_TABLE_ABSENTE'],
['TABLE_INCONNUE listes', 'PLAN_LISTES'],
['INCONNU', 'PLAN_INCONNU'],
['EXCLU_PLACE', 'PLAN_EXCLU_PLACE'],
['DOUBLE_PLACE', 'PLAN_DOUBLE_PLACE'],
['NON_ASSIS', 'PLAN_NON_ASSIS'],
]);
const SANS_REFUS = new Set(['CAPACITE', 'RESERVATION']);
// Ce qu'indexerPlan fait du plan selon verifierInvariants, la réserve
// permise comme la forme indexée la porte : refuser sous le code de la
// première violation de forme, en désignant le même participant, la même
// table et le même tour ; accepter quand il n'y en a aucune.
function refusAttendu(plan) {
const forme = verifierInvariants(INSTANCE, plan, { reserveAutorisee: true })
.filter(({ code }) => !SANS_REFUS.has(code));
if (forme.length === 0) return 'accepté';
const [premiere] = forme;
const { participant: id, table: idTable, tour } = premiere;
return { code: REFUS.get(cle(premiere)), participant: id, table: idTable, tour };
}
// Ce qu'indexerPlan fait du plan, sous la même forme. Une erreur qui n'est
// pas un refus du contenu se propage.
function refusObtenu(plan) {
try {
indexerPlan(INSTANCE, plan);
return 'accepté';
} catch (erreur) {
if (!(erreur instanceof ErreurConfiguration)) throw erreur;
const { participant: id, table: idTable, tour } = erreur.details;
return { code: erreur.code, participant: id, table: idTable, tour };
}
}
// Le plan parfait, chaque plan du tableau des mutations, et pour chaque
// participant à chaque tour : retiré, passé en réserve, ajouté à la réserve.
function plansEprouves() {
const plans = [['le plan parfait', PLAN_PARFAIT]];
for (const [libelle, modifier] of MUTATIONS) plans.push([libelle, planMute(modifier)]);
for (let tour = 1; tour <= 4; tour += 1) {
for (let numero = 1; numero <= 12; numero += 1) {
const id = participant(numero);
plans.push([`${numero} retiré du tour ${tour}`, planMute((plan) => {
retirer(plan.tours[tour - 1], id);
})]);
plans.push([`${numero} passé en réserve au tour ${tour}`, planMute((plan) => {
retirer(plan.tours[tour - 1], id);
plan.reserves[tour - 1].push(id);
})]);
plans.push([`${numero} ajouté à la réserve du tour ${tour}`, planMute((plan) => {
plan.reserves[tour - 1].push(id);
})]);
}
}
return plans;
}
test('indexerPlan refuse la première violation de forme que relève verifierInvariants, et accepte le plan qui n’en a aucune', () => {
const plans = plansEprouves();
const acceptes = plans.filter(([, plan]) => refusAttendu(plan) === 'accepté').length;
assert.ok(acceptes > 0 && acceptes < plans.length, `${acceptes} plans acceptés sur ${plans.length}`);
const cas = plans.map(([libelle, plan]) => [libelle, () => refusObtenu(plan), refusAttendu(plan)]);
assert.deepEqual(ecarts(cas), []);
});
});
describe('verifierIndicateurs : la ligne de décomposition (§ 12.3, § 12.10.5)', () => {
// Chiffres du plan parfait, dans l'ordre canonique : N − 1 = 11, puis 8 pour
// chacun. Chaque inégalité y tient, les deux dernières avec égalité.
const N = 12;
const huit = () => Array.from({ length: N }, () => 8);
// Copie de huit() dont la case index prend la valeur donnée.
const sauf = (index, valeur) => huit().map((v, p) => (p === index ? valeur : v));
// Mutations d'une seule case, de rang index : [libellé, rencontres,
// a priori, réalisé, code attendu]. null en a priori : inconnu.
const mutations = (index) => [
['a priori 12 > N − 1', huit(), sauf(index, 12), huit(), 'PLAFOND_A_PRIORI'],
['a priori 7 < réalisé 8', huit(), sauf(index, 7), huit(), 'ORDRE_PLAFONDS'],
['réalisé 9 > a priori 8', huit(), huit(), sauf(index, 9), 'ORDRE_PLAFONDS'],
['rencontres 9 > réalisé 8', sauf(index, 9), huit(), huit(), 'DEPASSEMENT'],
['réalisé 7 < rencontres 8', huit(), huit(), sauf(index, 7), 'DEPASSEMENT'],
['a priori inconnu, réalisé 12 > N − 1', huit(), null, sauf(index, 12), 'PLAFOND_REALISE'],
['a priori inconnu, rencontres 9 > réalisé 8', sauf(index, 9), null, huit(), 'DEPASSEMENT'],
];
test('les chiffres du plan parfait tiennent, le plafond a priori connu ou inconnu', () => {
assert.deepEqual(verifierIndicateurs({ rencontres: huit() }, huit(), huit()), []);
assert.deepEqual(verifierIndicateurs({ rencontres: huit() }, null, huit()), []);
});
test('ne modifie ni les mesures ni les plafonds, que la ligne tienne ou qu’elle tombe', () => {
// Entrées gelées : une écriture, un tri en place ou un champ ajouté y lève
// TypeError. La copie, prise avant l'appel, verrait de plus ce qu'aurait
// changé une entrée qu'on aurait oublié de geler.
const cas = [
['la ligne tient', huit(), huit(), huit()],
['la ligne tient, a priori inconnu', huit(), null, huit()],
...mutations(5).map(([libelle, rencontres, aPriori, realises]) => [libelle, rencontres, aPriori, realises]),
].map(([libelle, rencontres, aPriori, realises]) => [libelle, () => {
const mesures = Object.freeze({ rencontres: Object.freeze(rencontres) });
if (aPriori !== null) Object.freeze(aPriori);
Object.freeze(realises);
const copie = structuredClone({ mesures, aPriori, realises });
verifierIndicateurs(mesures, aPriori, realises);
assert.deepEqual({ mesures, aPriori, realises }, copie);
return 'intactes';
}, 'intactes']);
assert.deepEqual(ecarts(cas), []);
});
test('chaque mutation fait tomber une inégalité de la ligne, et aucune autre', () => {
const cas = mutations(5).map(([libelle, rencontres, aPriori, realises, code]) => [
libelle,
() => verifierIndicateurs({ rencontres }, aPriori, realises),
[{ code, index: 5 }],
]);
assert.deepEqual(ecarts(cas), []);
});
test('chaque personne est éprouvée, sur chaque inégalité', () => {
const cas = [];
for (let index = 0; index < N; index += 1) {
for (const [libelle, rencontres, aPriori, realises, code] of mutations(index)) {
cas.push([
`${libelle}, rang ${index}`,
() => verifierIndicateurs({ rencontres }, aPriori, realises),
[{ code, index }],
]);
}
}
assert.equal(cas.length, N * 7);
assert.deepEqual(ecarts(cas), []);
});
test('chaque inégalité est large : l’égalité tient sur toute la ligne', () => {
const onze = Array.from({ length: N }, () => 11);
assert.deepEqual(verifierIndicateurs({ rencontres: onze }, onze, onze), []);
assert.deepEqual(verifierIndicateurs({ rencontres: onze }, null, onze), []);
});
test('les violations se rangent par index croissant, puis de gauche à droite sur la ligne', () => {
// Au rang 2, les trois inégalités tombent : a priori 12 > N − 1, réalisé
// 13 > a priori 12, rencontres 14 > réalisé 13. Au rang 7, la dernière
// seule : rencontres 9 > réalisé 8.
const rencontres = sauf(2, 14);
rencontres[7] = 9;
const realises = sauf(2, 13);
assert.deepEqual(verifierIndicateurs({ rencontres }, sauf(2, 12), realises), [
{ code: 'PLAFOND_A_PRIORI', index: 2 },
{ code: 'ORDRE_PLAFONDS', index: 2 },
{ code: 'DEPASSEMENT', index: 2 },
{ code: 'DEPASSEMENT', index: 7 },
]);
assert.deepEqual(verifierIndicateurs({ rencontres }, null, realises), [
{ code: 'PLAFOND_REALISE', index: 2 },
{ code: 'DEPASSEMENT', index: 2 },
{ code: 'DEPASSEMENT', index: 7 },
]);
});
test('les tableaux typés du moteur se lisent comme des listes', () => {
const type = (valeurs) => Int32Array.from(valeurs);
assert.deepEqual(verifierIndicateurs({ rencontres: type(huit()) }, type(huit()), type(huit())), []);
assert.deepEqual(
verifierIndicateurs({ rencontres: type(huit()) }, type(sauf(3, 7)), type(huit())),
[{ code: 'ORDRE_PLAFONDS', index: 3 }],
);
});
test('une valeur qui n’est pas un nombre fait tomber les inégalités qui la lisent', () => {
// Converti, un texte tiendrait l'inégalité face à un 8 : '8' en terme de
// droite, '9' en terme de gauche. Chaque côté a donc son contrôle de type
// éprouvé.
const cas = [
['rencontres NaN', sauf(3, Number.NaN), huit(), huit(), ['DEPASSEMENT']],
['réalisé NaN', huit(), huit(), sauf(3, Number.NaN), ['ORDRE_PLAFONDS', 'DEPASSEMENT']],
['a priori NaN', huit(), sauf(3, Number.NaN), huit(), ['PLAFOND_A_PRIORI', 'ORDRE_PLAFONDS']],
['a priori null', huit(), sauf(3, null), huit(), ['PLAFOND_A_PRIORI', 'ORDRE_PLAFONDS']],
['réalisé indéfini', huit(), huit(), sauf(3, undefined), ['ORDRE_PLAFONDS', 'DEPASSEMENT']],
['rencontres en texte', sauf(3, '8'), huit(), huit(), ['DEPASSEMENT']],
['réalisé en texte', huit(), huit(), sauf(3, '9'), ['ORDRE_PLAFONDS', 'DEPASSEMENT']],
['a priori en texte', huit(), sauf(3, '9'), huit(), ['PLAFOND_A_PRIORI', 'ORDRE_PLAFONDS']],
['a priori inconnu, réalisé null', huit(), null, sauf(3, null), ['PLAFOND_REALISE', 'DEPASSEMENT']],
].map(([libelle, rencontres, aPriori, realises, codes]) => [
libelle,
() => verifierIndicateurs({ rencontres }, aPriori, realises),
codes.map((code) => ({ code, index: 3 })),
]);
assert.deepEqual(ecarts(cas), []);
});
test('un tableau de plafonds d’une autre longueur que les rencontres lève RangeError', () => {
const court = huit().slice(1);
const long = [...huit(), 8];
assert.throws(() => verifierIndicateurs({ rencontres: huit() }, huit(), court), RangeError);
assert.throws(() => verifierIndicateurs({ rencontres: huit() }, long, huit()), RangeError);
assert.throws(() => verifierIndicateurs({ rencontres: huit() }, null, long), RangeError);
});
test('un plafond qui n’est pas une liste lève TypeError, et seul null dit l’a priori inconnu', () => {
// undefined est la valeur d'un champ manquant : lu comme l'a priori
// inconnu, il ôterait deux inégalités à la garde sans rien signaler.
const rencontres = huit();
for (const valeur of [undefined, 8, {}]) {
assert.throws(() => verifierIndicateurs({ rencontres }, valeur, huit()),
{ name: 'TypeError', message: /plafondsAPriori/ }, `a priori ${String(valeur)}`);
}
for (const valeur of [undefined, null, 8, {}]) {
assert.throws(() => verifierIndicateurs({ rencontres }, huit(), valeur),
{ name: 'TypeError', message: /plafondsRealises/ }, `réalisé ${String(valeur)}`);
}
});
});

8
src/version.genere.js Normal file
View file

@ -0,0 +1,8 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Engendré par scripts/version.js depuis version.json — ne pas modifier.
export const VERSION = Object.freeze({
affichee: '2026.10.05.01',
technique: '2026.1005.1',
});

772
test/arborescence.test.js Normal file
View file

@ -0,0 +1,772 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Gardes de l'arborescence, lues dans le texte des sources : le moteur et le
// générateur de démonstrations n'appellent aucune source non reproductible
// (§ 14.7), et ne parcourent les clés d'un objet que par exception nommée
// (§ 15.5, point 4) ; le graphe d'imports du moteur et de la géométrie ne
// rejoint ni l'interface, ni le stockage, ni une plateforme (§ 13.4) ;
// chaque paquet importé est déclaré dans devDependencies ; une valeur du
// contrat de données n'a qu'une définition, celle de configuration.js.
// Chaque relevé lit le texte entier, commentaires et chaînes compris : un
// commentaire qui nomme un appel refusé fait échouer la garde comme l'appel
// lui-même. Chaque garde refuse de passer sur un balayage vide (§ 14.2), et
// d'autres épreuves la font tourner sur des arbres temporaires qui portent,
// à plus d'un niveau de profondeur, les formes qu'elle refuse et des formes
// voisines qu'elle admet.
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { isBuiltin } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, extname, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { chargements } from './chargements.js';
import { describe, test } from './lanceur.js';
const RACINE = fileURLToPath(new URL('..', import.meta.url));
// Écrit dans un répertoire temporaire l'arbre { chemin relatif: contenu }, le
// passe à examiner, puis l'efface ; rend ce que rend examiner.
function avecArbre(fichiers, examiner) {
const racine = mkdtempSync(join(tmpdir(), 'arborescence-'));
try {
for (const [chemin, contenu] of Object.entries(fichiers)) {
mkdirSync(dirname(join(racine, chemin)), { recursive: true });
writeFileSync(join(racine, chemin), contenu);
}
return examiner(racine);
} finally {
rmSync(racine, { recursive: true, force: true });
}
}
// Fichiers d'un arbre, à toute profondeur, en chemins absolus. Un arbre
// absent n'en fournit aucun.
function fichiersDe(dossier) {
try {
return readdirSync(dossier, { withFileTypes: true, recursive: true })
.filter((e) => e.isFile())
.map((e) => join(e.parentPath, e.name));
} catch (erreur) {
if (erreur.code === 'ENOENT') return [];
throw erreur;
}
}
// Modules d'un arbre : ses fichiers JavaScript, épreuves exclues, triés.
const EXTENSIONS_MODULE = new Set(['.js', '.mjs', '.cjs']);
const EPREUVE = /\.test\.[cm]?js$/;
const modulesDe = (dossier) =>
fichiersDe(dossier)
.filter((fichier) => EXTENSIONS_MODULE.has(extname(fichier)) && !EPREUVE.test(fichier))
.sort();
// Numéro, à partir de 1, de la ligne qui porte le caractère d'indice donné.
const ligneDe = (texte, indice) => texte.slice(0, indice).split('\n').length;
// Appels dont le résultat change d'une exécution à l'autre, ou d'un poste à
// l'autre : localeCompare, Intl et les méthodes toLocale… suivent la langue
// et les données régionales du moteur d'exécution (§ 14.7, § 15.5). Chaque
// motif à point admet des blancs autour de lui, que la mise en forme
// introduit quand elle coupe une chaîne d'appels, et le chaînage optionnel.
// Math, Date et performance suivis d'un crochet, accolé ou après « ?. »,
// sont refusés eux aussi, quel que soit le membre nommé. new Date se relève
// avec ou sans parenthèses, et Date appelée sans new, « Date() » comme
// « Date?.() », rend elle aussi l'heure courante. getRandomValues,
// randomUUID et les tirages de node:crypto — randomInt, randomBytes,
// randomFill — se reconnaissent au nom de la fonction, quel que soit l'objet
// qui la porte ou l'import qui la nomme.
const SOURCES_NON_REPRODUCTIBLES = [
['Math.random', /\bMath\s*\??\.\s*random\b/g],
['Math[', /\bMath\s*(?:\?\.)?\s*\[/g],
['Date.now', /\bDate\s*\??\.\s*now\b/g],
['Date[', /\bDate\s*(?:\?\.)?\s*\[/g],
['new Date', /\bnew\s+Date\b/g],
['Date()', /(?<!\bnew\s+)\bDate\s*(?:\?\.\s*)?\(/g],
['performance.now', /\bperformance\s*\??\.\s*now\b/g],
['performance[', /\bperformance\s*(?:\?\.)?\s*\[/g],
['crypto.getRandomValues', /\bgetRandomValues\b/g],
['crypto.randomUUID', /\brandomUUID\b/g],
['localeCompare', /\blocaleCompare\b/g],
['toLocale…', /\btoLocale\w*/g],
['Intl', /\bIntl\b/g],
['crypto.randomInt', /\brandomInt\b/g],
['crypto.randomBytes', /\brandomBytes\b/g],
['crypto.randomFill', /\brandomFill(?:Sync)?\b/g],
];
// Parcours des clés d'un objet (§ 15.5, point 4) : les clés entières y
// viennent d'abord, croissantes, puis les autres dans l'ordre où l'objet les
// a reçues. Un résultat qui en dépend change quand l'objet se construit
// autrement, sans qu'aucune épreuve ne le voie ; un départage se fait par
// identifiant entier. Les motifs à point suivent les règles de
// SOURCES_NON_REPRODUCTIBLES, et Object suivi d'un crochet est refusé quel
// que soit le membre nommé. for…in se relève sur une variable, un chemin
// pointé ou un motif de déstructuration suivi de « in », avec ou sans
// déclaration : la boucle à compteur, for…of et l'opérateur in hors d'une
// boucle n'en sont pas.
const PARCOURS_DE_CLES = [
['Object.keys', /\bObject\s*\??\.\s*keys\b/g],
['Object.entries', /\bObject\s*\??\.\s*entries\b/g],
['Object.values', /\bObject\s*\??\.\s*values\b/g],
['Object[', /\bObject\s*(?:\?\.)?\s*\[(?!\s*\])/g],
['for…in', /\bfor\s*\(\s*(?:(?:const|let|var)\s+)?(?:[\p{L}_$][\p{L}\p{N}_$.]*|\[[^\]]*\]|\{[^}]*\})\s+in\b/gu],
['Reflect.ownKeys', /\bReflect\s*\??\.\s*ownKeys\b/g],
['Object.getOwnPropertyNames', /\bObject\s*\??\.\s*getOwnPropertyNames\b/g],
];
// Parcours de clés admis, chacun nommé par son fichier, relatif à la racine,
// et par le texte de sa ligne, blancs de bord retirés ; sa raison dit
// pourquoi l'ordre n'y décide d'aucun résultat. Une exception admet chaque
// ligne de ce texte dans ce fichier, et rien d'autre : la ligne réécrite, ou
// un second parcours ajouté à côté, se relève de nouveau. Elle ne couvre
// aucune source de SOURCES_NON_REPRODUCTIBLES, que rien n'admet.
const PARCOURS_DE_CLES_ADMIS = [];
// Relevé « fichier:ligne appel » des sources non reproductibles et des
// parcours de clés non admis dans les modules de src/moteur et de src/demo,
// par fichier puis par ligne, et sur une même ligne dans l'ordre de
// SOURCES_NON_REPRODUCTIBLES puis de PARCOURS_DE_CLES. Les épreuves en sont
// exclues : un tirage y sert légitimement (§ 14.12). Suit une ligne
// « exception sans objet : fichier « texte » » par exception qui n'admet
// rien, dans l'ordre de la liste. Lève quand l'un des deux arbres ne fournit
// aucun module.
function releverDeterminisme(racine, admis = PARCOURS_DE_CLES_ADMIS) {
const servies = new Set();
const estAdmis = (fichier, texteDeLigne) => {
const exception = admis.find((e) => e.fichier === fichier && e.ligne === texteDeLigne);
if (exception !== undefined) servies.add(exception);
return exception !== undefined;
};
const releve = ['src/moteur', 'src/demo'].flatMap((arbre) => {
const modules = modulesDe(join(racine, arbre));
assert.ok(modules.length > 0, `${arbre} ne fournit aucun module`);
return modules.flatMap((fichier) => {
const nom = relative(racine, fichier);
const texte = readFileSync(fichier, 'utf8');
const lignes = texte.split('\n');
const trouves = (motifs) =>
motifs.flatMap(([appel, motif]) =>
[...texte.matchAll(motif)].map(({ index }) => ({ ligne: ligneDe(texte, index), appel })),
);
return [
...trouves(SOURCES_NON_REPRODUCTIBLES),
...trouves(PARCOURS_DE_CLES).filter(({ ligne }) => !estAdmis(nom, lignes[ligne - 1].trim())),
]
.sort((a, b) => a.ligne - b.ligne)
.map(({ ligne, appel }) => `${nom}:${ligne} ${appel}`);
});
});
const sansObjet = admis
.filter((exception) => !servies.has(exception))
.map(({ fichier, ligne }) => `exception sans objet : ${fichier} « ${ligne} »`);
return [...releve, ...sansObjet];
}
describe('arborescence : déterminisme (§ 14.7)', () => {
test("ni src/moteur ni src/demo ne lisent une source non reproductible, ni ne parcourent les clés d'un objet hors des exceptions nommées", () => {
assert.deepEqual(releverDeterminisme(RACINE), []);
});
test('les tirages de node:crypto se relèvent comme ceux du navigateur', () => {
const fichiers = {
'src/moteur/a.js': 'export const a = 1;\n',
'src/demo/tirage.js': [
"import { randomInt, randomBytes, randomFillSync } from 'node:crypto';",
'export const a = randomInt(6);',
'export const b = randomBytes(4);',
'export const c = randomFillSync(new Uint8Array(4));',
].join('\n'),
};
assert.deepEqual(avecArbre(fichiers, releverDeterminisme), [
'src/demo/tirage.js:1 crypto.randomInt',
'src/demo/tirage.js:1 crypto.randomBytes',
'src/demo/tirage.js:1 crypto.randomFill',
'src/demo/tirage.js:2 crypto.randomInt',
'src/demo/tirage.js:3 crypto.randomBytes',
'src/demo/tirage.js:4 crypto.randomFill',
]);
});
test('la garde relève chaque appel par sa ligne, à toute profondeur, hors épreuves, et refuse un arbre sans module', () => {
const fichiers = {
'src/moteur/tirage.js': [
'export const a = Math.random();',
'export const b = Date.now() - performance.now();',
'export const c = new Date(0);',
'export const d = globalThis.crypto.getRandomValues(new Uint32Array(1));',
"export const e = ['b', 'a'].sort((x, y) => x.localeCompare(y));",
'export const f = Math',
' .random;',
'export const i = Date',
' .now() + performance .now();',
'export const j = globalThis.crypto',
' .getRandomValues(t);',
'export const k = new Date;',
'export const p = Date();',
'export const q = crypto.randomUUID();',
"export const r = Math['random']();",
"export const s = new Intl.Collator('fr').compare;",
"export const u = 'É'.toLocaleLowerCase();",
'export const v = webcrypto.getRandomValues(t);',
'const { Collator } = Intl;',
'export const x = source.randomUUID();',
'export const y = (1234.5).toLocaleString();',
'export const t1 = globalThis.performance?.now?.() ?? 0;',
'export const t2 = Date?.now() + Math?.random();',
"export const t3 = performance['now']();",
"export const t4 = Date['now']() + Math?.['random']();",
"export const t5 = Date?.['now']() - performance?.['now']();",
'export const t6 = Date?.();',
].join('\n'),
'src/moteur/tirage.test.js': 'export const g = Math.random();\n',
'src/moteur/sous/profond.js': 'export const l = Math.random();\n',
'src/demo/propre.js': [
'export const h = Math.floor(2.5) + Date.UTC(2000, 0, 1);',
'export const mathRandom = 1;',
'export const debutDate = { now: 2 };',
'export const m = debutDate.now + xMath.random;',
"export const w = 'É'.toLowerCase();",
].join('\n'),
'src/demo/tirage.mjs': 'export const n = Math.random();\n',
'src/demo/tirage.cjs': 'module.exports = Date.now();\n',
'src/demo/tirage.test.mjs': 'export const o = Math.random();\n',
'src/demo/tirage.test.cjs': 'module.exports = Math.random();\n',
};
assert.deepEqual(avecArbre(fichiers, releverDeterminisme), [
'src/moteur/sous/profond.js:1 Math.random',
'src/moteur/tirage.js:1 Math.random',
'src/moteur/tirage.js:2 Date.now',
'src/moteur/tirage.js:2 performance.now',
'src/moteur/tirage.js:3 new Date',
'src/moteur/tirage.js:4 crypto.getRandomValues',
'src/moteur/tirage.js:5 localeCompare',
'src/moteur/tirage.js:6 Math.random',
'src/moteur/tirage.js:8 Date.now',
'src/moteur/tirage.js:9 performance.now',
'src/moteur/tirage.js:11 crypto.getRandomValues',
'src/moteur/tirage.js:12 new Date',
'src/moteur/tirage.js:13 Date()',
'src/moteur/tirage.js:14 crypto.randomUUID',
'src/moteur/tirage.js:15 Math[',
'src/moteur/tirage.js:16 Intl',
'src/moteur/tirage.js:17 toLocale…',
'src/moteur/tirage.js:18 crypto.getRandomValues',
'src/moteur/tirage.js:19 Intl',
'src/moteur/tirage.js:20 crypto.randomUUID',
'src/moteur/tirage.js:21 toLocale…',
'src/moteur/tirage.js:22 performance.now',
'src/moteur/tirage.js:23 Math.random',
'src/moteur/tirage.js:23 Date.now',
'src/moteur/tirage.js:24 performance[',
'src/moteur/tirage.js:25 Math[',
'src/moteur/tirage.js:25 Date[',
'src/moteur/tirage.js:26 Date[',
'src/moteur/tirage.js:26 performance[',
'src/moteur/tirage.js:27 Date()',
'src/demo/tirage.cjs:1 Date.now',
'src/demo/tirage.mjs:1 Math.random',
]);
assert.throws(
() => avecArbre({ 'src/demo/propre.js': '' }, releverDeterminisme),
/src\/moteur ne fournit aucun module/,
);
assert.throws(
() => avecArbre({ 'src/moteur/a.js': '', 'src/demo/a.test.js': '' }, releverDeterminisme),
/src\/demo ne fournit aucun module/,
);
});
test("les parcours des clés d'un objet se relèvent par leur ligne, hors épreuves, et pas leurs voisins (§ 15.5, point 4)", () => {
const fichiers = {
'src/moteur/cles.js': [
'export const a = Object.keys(o);',
'export const b = Object.entries(o).map(f);',
'export const c = Object.values(o);',
'for (const k in o) t.push(k);',
'for (k in o) t.push(k);',
'export const d = Object',
' .keys(o);',
'export const e = Object?.entries?.(o);',
"export const g = Object['keys'](o);",
'for(let k in o){}',
'for (const [k] in o) t.push(k);',
'for (var { length } in o) t.push(length);',
'export const h = Reflect.ownKeys(o);',
'export const i = Object.getOwnPropertyNames(o);',
'for (const k of Object.keys(o)) t.push(k);',
'for (a.b in o) t.push(a.b);',
].join('\n'),
'src/moteur/cles.test.js': 'export const x = Object.keys(o);\n',
'src/demo/voisins.js': [
'for (let i = 0; i < n; i += 1) t.push(i);',
'for (const v of Object.freeze([])) t.push(v);',
"export const f = 'k' in o;",
'for (const [k, v] of m) t.push(k, v);',
'export const z = monObject.keys + objectKeys;',
'export const w = Object.fromEntries(paires);',
'for (const index of inventaire) t.push(index);',
'/** @param {Object[]} entrees les entrées, dans leur ordre */',
].join('\n'),
};
assert.deepEqual(avecArbre(fichiers, releverDeterminisme), [
'src/moteur/cles.js:1 Object.keys',
'src/moteur/cles.js:2 Object.entries',
'src/moteur/cles.js:3 Object.values',
'src/moteur/cles.js:4 for…in',
'src/moteur/cles.js:5 for…in',
'src/moteur/cles.js:6 Object.keys',
'src/moteur/cles.js:8 Object.entries',
'src/moteur/cles.js:9 Object[',
'src/moteur/cles.js:10 for…in',
'src/moteur/cles.js:11 for…in',
'src/moteur/cles.js:12 for…in',
'src/moteur/cles.js:13 Reflect.ownKeys',
'src/moteur/cles.js:14 Object.getOwnPropertyNames',
'src/moteur/cles.js:15 Object.keys',
'src/moteur/cles.js:16 for…in',
]);
});
test("une exception nommée n'admet que la ligne qu'elle cite, dans son fichier, et qu'un parcours de clés ; une exception sans objet se relève", () => {
const somme = 'export const total = (o) => Object.values(o).reduce((s, v) => s + v, 0);';
const fichiers = {
'src/moteur/a.js': 'export const a = Math.random();\n',
'src/demo/somme.js': [
somme,
'export const nombre = (o) => Object.keys(o).length;',
'export const double = (o) => Object.values(o).reduce((s, v) => s + 2 * v, 0);',
].join('\n'),
'src/demo/autre.js': `${somme}\n`,
};
const exceptions = [
{ fichier: 'src/demo/somme.js', ligne: somme, raison: "une somme d'entiers ne dépend pas de l'ordre de ses termes" },
{ fichier: 'src/moteur/a.js', ligne: 'export const a = Math.random();', raison: 'un tirage ne se nomme pas en exception' },
{ fichier: 'src/demo/disparu.js', ligne: 'for (const k in o) t.push(k);', raison: 'la ligne a disparu' },
];
assert.deepEqual(avecArbre(fichiers, (racine) => releverDeterminisme(racine, exceptions)), [
'src/moteur/a.js:1 Math.random',
'src/demo/autre.js:1 Object.values',
'src/demo/somme.js:2 Object.keys',
'src/demo/somme.js:3 Object.values',
'exception sans objet : src/moteur/a.js « export const a = Math.random(); »',
'exception sans objet : src/demo/disparu.js « for (const k in o) t.push(k); »',
]);
});
});
// Chargements d'un texte qui désignent un spécificateur, dans l'ordre du
// texte, chacun { indice, specificateur } : ceux que relève l'analyseur
// commun (test/chargements.js), moins les arguments calculés, qui ne
// désignent aucun module.
const chargementsLitteraux = (texte) =>
chargements(texte).filter(({ specificateur }) => specificateur !== undefined);
// « ./ » ou « ../ » en tête, ou « . » et « .. » seuls.
const RELATIF = /^\.{1,2}(?:\/|$)/;
// Nom du paquet que désigne un spécificateur nu : son premier segment, ou
// ses deux premiers pour un paquet à portée (@portée/nom).
const nomDePaquet = (specificateur) =>
specificateur.split('/').slice(0, specificateur.startsWith('@') ? 2 : 1).join('/');
// Ce que ni le moteur ni la géométrie ne chargent (§ 13.4), outre les
// modules natifs de Node : les paquets de l'interface et des plateformes,
// sous-chemins compris, dont le motif s'applique au nom de paquet, et les
// couches de src/ qui touchent l'écran ou les fichiers.
const PAQUETS_EXTERIEURS = /^(?:svelte|electron|@capacitor\/.+)$/;
const COUCHES_EXTERIEURES = ['interface', 'stockage'];
// Un accès au navigateur est un nom de document, de window, de navigator, de
// location, de localStorage ou de sessionStorage suivi d'un crochet, ou
// d'un point puis d'un nom de propriété, accolé ou en tête de la ligne
// suivante. Une phrase qui continue après « document. » sur la même ligne
// n'en est pas un, ni une ligne qui finit sur ce mot quand la suivante
// s'ouvre sur autre chose qu'un nom, un commentaire par exemple. Le relevé
// ne distingue pas le commentaire du code : une ligne de commentaire qui
// finit sur « document. » juste avant une ligne de code se lit comme un
// accès.
const ACCES_AU_DOM = /\b(document|window|navigator|location|localStorage|sessionStorage)\s*(?:\??\.(?=[\p{L}_$]|[ \t]*\r?\n\s*[\p{L}_$])|(?:\?\.)?\s*\[)/gu;
// Chemin que désigne un spécificateur relatif, ou absolu depuis la racine
// comme Vite le résout ; null pour un spécificateur nu ou une URL.
function cibleDe(racine, fichier, specificateur) {
if (RELATIF.test(specificateur)) return resolve(dirname(fichier), specificateur);
if (specificateur.startsWith('/')) return join(racine, specificateur);
return null;
}
// Relevé des refus de la frontière des couches dans le graphe d'imports du
// moteur et de la géométrie. Le parcours part des modules de src/moteur et
// de src/geometrie, à toute profondeur, épreuves exclues. Il suit chaque
// chemin relatif, ou absolu depuis la racine, vers un module JavaScript
// existant, chaque module une fois. Un fichier d'une couche extérieure n'est
// pas parcouru : l'importer est déjà un refus. Un chemin sans extension en
// est un aussi : Node ne le résout pas (§ 14.8), et le parcours ne suit pas
// le module que Vite y trouverait. Un module natif de Node, que reconnaît
// isBuiltin, est refusé : la page où le moteur s'exécute n'en a aucun.
// Chaque refus se lit « accès:ligne refus », où l'accès est la suite des
// modules qui mène d'un module de départ à celui qui refuse. src/geometrie
// peut manquer ; lève quand src/moteur ne fournit aucun module.
function releverFrontiere(racine) {
const moteur = modulesDe(join(racine, 'src', 'moteur'));
assert.ok(moteur.length > 0, 'src/moteur ne fournit aucun module');
const couches = COUCHES_EXTERIEURES.map((couche) => join(racine, 'src', couche));
const exterieur = (chemin) => couches.some((c) => chemin === c || chemin.startsWith(c + sep));
const aParcourir = [...moteur, ...modulesDe(join(racine, 'src', 'geometrie'))];
const acces = new Map(aParcourir.map((fichier) => [fichier, relative(racine, fichier)]));
const refus = [];
for (let i = 0; i < aParcourir.length; i += 1) {
const fichier = aParcourir[i];
const texte = readFileSync(fichier, 'utf8');
const trouves = [...texte.matchAll(ACCES_AU_DOM)].map(({ 0: lu, 1: nom, index }) => ({
indice: index,
motif: nom + lu.at(-1),
}));
for (const { specificateur, indice } of chargementsLitteraux(texte)) {
const cible = cibleDe(racine, fichier, specificateur);
if (cible === null) {
if (isBuiltin(specificateur) || PAQUETS_EXTERIEURS.test(nomDePaquet(specificateur))) {
trouves.push({ indice, motif: `import ${specificateur}` });
}
} else if (exterieur(cible)) {
trouves.push({ indice, motif: `import ${specificateur}` });
} else if (extname(cible) === '') {
trouves.push({ indice, motif: `import ${specificateur} sans extension` });
} else if (!acces.has(cible) && EXTENSIONS_MODULE.has(extname(cible)) && existsSync(cible)) {
acces.set(cible, `${acces.get(fichier)} → ${relative(racine, cible)}`);
aParcourir.push(cible);
}
}
trouves.sort((a, b) => a.indice - b.indice);
for (const { indice, motif } of trouves) {
refus.push(`${acces.get(fichier)}:${ligneDe(texte, indice)} ${motif}`);
}
}
return refus;
}
describe('arborescence : frontière des couches (§ 13.4)', () => {
test('les globales du navigateur hors du DOM se refusent comme lui : navigator, location, localStorage, sessionStorage', () => {
const fichiers = {
'src/moteur/a.js': [
'export const langue = navigator.language;',
'export const adresse = location.href;',
"export const memoire = localStorage.getItem('x');",
"export const seance = sessionStorage['x'];",
].join('\n'),
};
assert.deepEqual(avecArbre(fichiers, releverFrontiere), [
'src/moteur/a.js:1 navigator.',
'src/moteur/a.js:2 location.',
'src/moteur/a.js:3 localStorage.',
'src/moteur/a.js:4 sessionStorage[',
]);
});
test("le graphe d'imports du moteur et de la géométrie ne rejoint ni l'interface, ni le stockage, ni une plateforme", () => {
assert.deepEqual(releverFrontiere(RACINE), []);
});
test('la garde suit les imports depuis chaque module à toute profondeur, relève chaque refus par sa ligne, hors épreuves', () => {
const fichiers = {
'src/moteur/a.js': [
"import { mount } from 'svelte';",
"import { Capacitor } from '@capacitor/core';",
"import { app } from 'electron';",
"import App from '../interface/App.svelte';",
"import { lire } from '../stockage/fichiers.js';",
"import { aide } from '../commun/aide.js';",
"import { ErreurConfiguration } from './erreurs.js';",
'export const largeur = document.body.clientWidth;',
"import { writable } from 'svelte/store';",
"import { ipcRenderer } from 'electron/renderer';",
"import { voisin } from '../interface-x/voisin.js';",
'export const adresse = window.URL;',
"import { x } from '/src/interface/x.js';",
"import { f } from '../commun/fenetre';",
"import { readFileSync } from 'node:fs';",
"export const hauteur = window['innerHeight'];",
'export const corps = () => document.',
' body;',
"import 'svelte';",
'import { y } from "electron";',
"import { z } from '@capacitor/cli';",
'const c = require("svelte");',
"export const echelle = window?.['devicePixelRatio'];",
"import fs from 'fs';",
].join('\n'),
'src/moteur/b.js': [
'import {',
" // l'écran d'accueil",
' App,',
"} from '../interface/App.svelte';",
].join('\n'),
'src/moteur/erreurs.js': [
'// Une phrase finit sur le mot document. La suivante commence ici.',
"import { rien } from './absent.js';",
'export const fenetres = { windows: 1, documentation: 2 };',
'// Cette ligne finit sur le mot document.',
'// La suivante est un commentaire.',
].join('\n'),
'src/moteur/sous/c.js': [
"import { x } from '../../interface/x.js';",
"import { y } from '../interface/y.js';",
].join('\n'),
'src/moteur/interface/y.js': 'export const y = 1;\n',
'src/moteur/a.test.js': "import { mount } from 'svelte';\nexport const corps = document.body;\n",
'src/stockage/fichiers.js': "import { Filesystem } from '@capacitor/core';\n",
'src/commun/aide.js': [
"import { largeur } from '../moteur/a.js';",
'export const aide = () => window',
' .innerWidth;',
].join('\n'),
'src/commun/fenetre.js': 'export const f = () => window.innerHeight;\n',
'src/commun/racine.js': 'export const r = () => window.top;\n',
'src/geometrie/echelle.js': [
'export const zoom = () => window?.devicePixelRatio;',
"import { r } from '/src/commun/racine.js';",
].join('\n'),
};
assert.deepEqual(avecArbre(fichiers, releverFrontiere), [
'src/moteur/a.js:1 import svelte',
'src/moteur/a.js:2 import @capacitor/core',
'src/moteur/a.js:3 import electron',
'src/moteur/a.js:4 import ../interface/App.svelte',
'src/moteur/a.js:5 import ../stockage/fichiers.js',
'src/moteur/a.js:8 document.',
'src/moteur/a.js:9 import svelte/store',
'src/moteur/a.js:10 import electron/renderer',
'src/moteur/a.js:12 window.',
'src/moteur/a.js:13 import /src/interface/x.js',
'src/moteur/a.js:14 import ../commun/fenetre sans extension',
'src/moteur/a.js:15 import node:fs',
'src/moteur/a.js:16 window[',
'src/moteur/a.js:17 document.',
'src/moteur/a.js:19 import svelte',
'src/moteur/a.js:20 import electron',
'src/moteur/a.js:21 import @capacitor/cli',
'src/moteur/a.js:22 import svelte',
'src/moteur/a.js:23 window[',
'src/moteur/a.js:24 import fs',
'src/moteur/b.js:4 import ../interface/App.svelte',
'src/moteur/sous/c.js:1 import ../../interface/x.js',
'src/geometrie/echelle.js:1 window.',
'src/moteur/a.js → src/commun/aide.js:2 window.',
'src/geometrie/echelle.js → src/commun/racine.js:1 window.',
]);
});
test('src/geometrie peut manquer ; un src/moteur sans module fait échouer la garde', () => {
assert.deepEqual(avecArbre({ 'src/moteur/a.js': '' }, releverFrontiere), []);
assert.throws(
() => avecArbre({ 'src/geometrie/echelle.js': '', 'src/moteur/a.test.js': '' }, releverFrontiere),
/src\/moteur ne fournit aucun module/,
);
});
});
// Périmètre du relevé des dépendances : les fichiers de la racine, et les
// arbres des sources, des épreuves, des scripts et de la coquille ; ni
// node_modules ni sortie de construction.
const ARBRES = ['src', 'test', 'scripts', 'electron'];
const EXTENSIONS_SOURCE = new Set(['.js', '.cjs', '.mjs', '.svelte']);
function sourcesDuProjet(racine) {
const racineSeule = readdirSync(racine, { withFileTypes: true })
.filter((e) => e.isFile())
.map((e) => join(racine, e.name));
return [...racineSeule, ...ARBRES.flatMap((arbre) => fichiersDe(join(racine, arbre)))]
.filter((chemin) => EXTENSIONS_SOURCE.has(extname(chemin)))
.sort();
}
// Un spécificateur nu n'est ni relatif, ni absolu : ni chemin, ni URL, sinon
// sous node:, la forme des modules natifs.
const URL_ABSOLUE = /^(?!node:)[a-z][a-z\d+.-]*:/i;
const estNu = (specificateur) =>
!RELATIF.test(specificateur) && !specificateur.startsWith('/') && !URL_ABSOLUE.test(specificateur);
// Relevé « fichier:ligne spécificateur » des spécificateurs nus qui ne
// désignent ni un module natif de Node ni un paquet de devDependencies ; un
// paquet que seul dependencies déclare est relevé lui aussi, le projet
// n'ayant aucune dépendance d'exécution. Node et Vite résolvent un
// spécificateur nu en remontant les node_modules des répertoires parents :
// un paquet non déclaré, présent dans un parent sur un poste, s'y charge,
// puis manque dans un clone. isBuiltin admet le nom seul d'un module natif et
// sa forme node:, la seule qu'acceptent certains (node:test) ; il refuse un
// nom inconnu sous node:. Lève quand le périmètre ne porte aucun
// spécificateur nu.
function releverDependances(racine) {
const { devDependencies = {} } = JSON.parse(readFileSync(join(racine, 'package.json'), 'utf8'));
const declares = new Set(Object.keys(devDependencies));
let nus = 0;
const refus = [];
for (const fichier of sourcesDuProjet(racine)) {
const texte = readFileSync(fichier, 'utf8');
const trouves = chargementsLitteraux(texte).filter(({ specificateur }) => estNu(specificateur));
nus += trouves.length;
for (const { specificateur, indice } of trouves) {
if (!isBuiltin(specificateur) && !declares.has(nomDePaquet(specificateur))) {
refus.push(`${relative(racine, fichier)}:${ligneDe(texte, indice)} ${specificateur}`);
}
}
}
assert.ok(nus > 0, 'aucun spécificateur nu dans le périmètre');
return refus;
}
// Un import de module seul par spécificateur, une ligne chacun, entre les
// guillemets donnés, doubles par défaut. Le texte de ce fichier ne porte
// ainsi aucun spécificateur entre guillemets après le mot-clé : un
// spécificateur refusé, écrit en clair dans une instruction d'import de ce
// fichier, ferait échouer la garde sur ce fichier même. Une donnée qui charge
// un paquet refusé par une autre forme l'interpole pour la même raison.
const importsSeuls = (specificateurs, guillemet = '"') =>
specificateurs.map((s) => `import ${guillemet}${s}${guillemet};`).join('\n');
describe('arborescence : dépendances déclarées', () => {
test('chaque spécificateur nu désigne un module natif de Node ou un paquet de devDependencies', () => {
assert.deepEqual(releverDependances(RACINE), []);
});
test('la garde relève chaque paquet absent de devDependencies, dans son périmètre seul', () => {
const fichiers = {
'package.json': JSON.stringify({
dependencies: { lodash: '^4.17.21' },
devDependencies: { vitest: '^5.0.3', '@vitest/browser-playwright': '^5.0.3' },
}),
'vite.config.js': "import { defineConfig } from 'vite';\n",
'src/interface/a.svelte': [
'<script>',
" import { mount } from 'svelte';",
'</script>',
'<style>',
" @import 'jetons.css';",
'</style>',
].join('\n'),
'electron/b.cjs': "const { app } = require('electron');\n",
'scripts/oracle/c.mjs': [
"const { chromium } = require('playwright');",
"import assert from 'node:assert/strict';",
'import {',
' describe,',
"} from 'vitest/config';",
"export * from '@vitest/browser-playwright';",
"const { join } = require('node:path');",
"const vue = await import('svelte/store');",
"import './voisin.js';",
"import '..';",
"spawnSync(process.execPath, ['--import', 'chargeur.js']);",
"import { build } from'vite';",
'const fc = await import(`fast-check`);',
"const { app } = await import('electron', { with: {} });",
].join('\n'),
'test/d.test.js': [
importsSeuls(['fs/promises', 'node:test', 'test', 'node:inexistant', '@vitest/inconnu']),
importsSeuls(['jsdom'], "'"),
importsSeuls(['/absolu.js', 'file:///e.js', 'lodash']),
`import x from ${JSON.stringify('happy-dom')};`,
`require(${JSON.stringify('undici')});`,
].join('\n'),
'node_modules/f/index.js': importsSeuls(['hors-perimetre']),
'www/g.js': importsSeuls(['hors-perimetre']),
};
assert.deepEqual(avecArbre(fichiers, releverDependances), [
'electron/b.cjs:1 electron',
'scripts/oracle/c.mjs:1 playwright',
'scripts/oracle/c.mjs:8 svelte/store',
'scripts/oracle/c.mjs:12 vite',
'scripts/oracle/c.mjs:13 fast-check',
'scripts/oracle/c.mjs:14 electron',
'src/interface/a.svelte:2 svelte',
'test/d.test.js:3 test',
'test/d.test.js:4 node:inexistant',
'test/d.test.js:5 @vitest/inconnu',
'test/d.test.js:6 jsdom',
'test/d.test.js:9 lodash',
'test/d.test.js:10 happy-dom',
'test/d.test.js:11 undici',
'vite.config.js:1 vite',
]);
});
test('un périmètre sans spécificateur nu fait échouer la garde', () => {
const fichiers = { 'package.json': '{}', 'src/a.js': "import './b.js';\n" };
assert.throws(() => avecArbre(fichiers, releverDependances), /aucun spécificateur nu/);
});
});
// Valeurs du contrat de données (types.js) : configuration.js les définit et
// les exporte, tout autre fichier de src/moteur les importe, épreuves
// comprises (§ 13.2). Une déclaration de l'un de ces noms ailleurs est une
// seconde définition, qui peut dériver de la première sans qu'aucune épreuve
// ne le voie. Le relevé lit le nom qui suit const, let ou var : une
// déstructuration de STATUT n'est pas relevée, elle lit la définition au lieu
// de la refaire.
const VALEURS_DU_CONTRAT = [
'STATUT',
'MOBILE',
'PARTIELLEMENT_FIXE',
'ANCRE',
'LIBRE',
'RESERVE',
'SANS_GROUPE',
];
const DECLARATION_DE_VALEUR = new RegExp(
`\\b(?:const|let|var)\\s+(${VALEURS_DU_CONTRAT.join('|')})\\b`,
'g',
);
const PROPRIETAIRE_DES_VALEURS = join('src', 'moteur', 'configuration.js');
// Relevé « fichier:ligne nom » des déclarations d'une valeur du contrat dans
// les fichiers JavaScript de src/moteur, à toute profondeur, épreuves
// comprises, configuration.js excepté ; par fichier, puis par ligne. Lève
// quand src/moteur ne fournit aucun fichier JavaScript.
function releverValeursDuContrat(racine) {
const fichiers = fichiersDe(join(racine, 'src', 'moteur'))
.filter((fichier) => EXTENSIONS_MODULE.has(extname(fichier)))
.sort();
assert.ok(fichiers.length > 0, 'src/moteur ne fournit aucun fichier JavaScript');
return fichiers
.filter((fichier) => relative(racine, fichier) !== PROPRIETAIRE_DES_VALEURS)
.flatMap((fichier) => {
const texte = readFileSync(fichier, 'utf8');
return [...texte.matchAll(DECLARATION_DE_VALEUR)].map(
(trouve) => `${relative(racine, fichier)}:${ligneDe(texte, trouve.index)} ${trouve[1]}`,
);
});
}
describe('arborescence : un seul propriétaire des valeurs du contrat (types.js)', () => {
test('hors de configuration.js, aucun fichier de src/moteur ne déclare STATUT, un statut, LIBRE, RESERVE ni SANS_GROUPE', () => {
assert.deepEqual(releverValeursDuContrat(RACINE), []);
});
test('la garde relève chaque déclaration par sa ligne, épreuves comprises, et admet le propriétaire, la déstructuration et les noms voisins', () => {
const fichiers = {
'src/moteur/configuration.js': 'export const STATUT = Object.freeze({ ANCRE: 2 });\nexport const LIBRE = -1;\n',
'src/moteur/a.js': [
"import { STATUT } from './configuration.js';",
'const { ANCRE } = STATUT;',
'const ANCRES = [];',
'let LIBRES = 0;',
].join('\n'),
'src/moteur/b.js': 'const ANCRE = 2;\nexport let RESERVE = -1;\n',
'src/moteur/sous/c.test.js': '// Épreuve.\nvar SANS_GROUPE = -1;\n',
};
assert.deepEqual(avecArbre(fichiers, releverValeursDuContrat), [
'src/moteur/b.js:1 ANCRE',
'src/moteur/b.js:2 RESERVE',
'src/moteur/sous/c.test.js:2 SANS_GROUPE',
]);
});
test('un src/moteur sans fichier JavaScript fait échouer la garde', () => {
for (const fichiers of [{ 'src/demo/a.js': '' }, { 'src/moteur/notes.md': '' }]) {
assert.throws(
() => avecArbre(fichiers, releverValeursDuContrat),
/src\/moteur ne fournit aucun fichier JavaScript/,
);
}
});
});

40
test/chargements.js Normal file
View file

@ -0,0 +1,40 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Analyseur des chargements qu'un texte demande, seul lu par les gardes de
// l'arborescence (test/arborescence.test.js) et du livrable
// (test/livrable.test.js) : import et export statiques, import seul, import()
// et require().
//
// Le relevé lit le texte entier, commentaires et chaînes compris. Une
// instruction à « from » se lit à partir de ce mot, que seuls import et
// export font suivre d'une chaîne : un motif parti du mot-clé s'arrêterait à
// l'apostrophe d'un commentaire placé entre les accolades. L'import d'un
// module seul exige un blanc après le mot-clé, et aucune arobase devant lui :
// une chaîne qui finit sur « import », comme l'argument « --import » d'une
// commande, n'en est pas un, ni la règle @import d'une feuille de style, dont
// la chaîne est une adresse relative à la feuille et non un paquet. import()
// et require() désignent un spécificateur quand leur argument est une chaîne
// littérale, entre guillemets, apostrophes ou accents graves sans
// interpolation, suivie ou non d'options ; tout autre argument est calculé.
const DEPUIS = /\bfrom\s*(['"])(?<specificateur>[^'"\n]+)\1/g;
const IMPORT_SEUL = /(?<!@)\bimport\s+(['"])(?<specificateur>[^'"\n]+)\1/g;
const APPEL =
/\b(?:import|require)\s*\(\s*(?:(['"`])(?<specificateur>[^'"`$\n]+)\1\s*[,)]|(?<argument>[^)]*)\))/g;
// Chargements que demande un texte, dans l'ordre du texte. Chacun est
// { indice, specificateur } pour une chaîne littérale, { indice, argument }
// pour un argument calculé, son texte jusqu'à la première parenthèse
// fermante, blancs de bord retirés. indice est la position du mot « from »,
// de « import » ou de « require » dans le texte.
export function chargements(texte) {
return [DEPUIS, IMPORT_SEUL, APPEL]
.flatMap((motif) =>
[...texte.matchAll(motif)].map(({ groups, index }) =>
groups.specificateur === undefined
? { indice: index, argument: groups.argument.trim() }
: { indice: index, specificateur: groups.specificateur },
),
)
.sort((a, b) => a.indice - b.indice);
}

91
test/chargements.test.js Normal file
View file

@ -0,0 +1,91 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// L'analyseur des chargements, test/chargements.js, que lisent les gardes de
// l'arborescence et du livrable. Chaque épreuve donne un texte et les
// chargements qu'il demande, dans l'ordre du texte. Les spécificateurs nus
// des données sont des paquets de devDependencies ou des modules de Node :
// la garde des dépendances lit aussi ce fichier.
import assert from 'node:assert/strict';
import { chargements } from './chargements.js';
import { describe, test } from './lanceur.js';
// Numéro, à partir de 1, de la ligne qui porte le caractère d'indice donné.
const ligneDe = (texte, indice) => texte.slice(0, indice).split('\n').length;
// Chaque chargement d'un texte, en « ligne spécificateur », ou en « ligne
// calculé argument » pour un argument qui n'est pas une chaîne littérale.
const lus = (texte) =>
chargements(texte).map(({ indice, specificateur, argument }) =>
specificateur === undefined
? `${ligneDe(texte, indice)} calculé ${argument}`
: `${ligneDe(texte, indice)} ${specificateur}`,
);
describe('chargements', () => {
test("un import dont les accolades portent un commentaire à apostrophe se relève, comme celui qui le suit", () => {
const texte = [
'import {',
" // l'écran d'accueil",
' App,',
"} from '../interface/App.svelte';",
"import { a } from './a.js';",
].join('\n');
assert.deepEqual(
chargements(texte).map(({ specificateur }) => specificateur),
['../interface/App.svelte', './a.js'],
);
});
test('import et export à from, import seul, import() et require() de chaîne, avec ou sans options, se relèvent à leur ligne, dans l’ordre du texte', () => {
const texte = [
"import { a } from './a.js';",
'import b, { c } from "../b.js";',
"export * from './c.js';",
"export { d } from'./d.js';",
"import 'svelte';",
'const e = require("electron");',
"const f = await import('node:fs', { with: {} });",
'const g = await import(`vitest`);',
"import * as h from 'svelte/store'; const i = require('./i.cjs');",
].join('\n');
assert.deepEqual(lus(texte), [
'1 ./a.js',
'2 ../b.js',
'3 ./c.js',
'4 ./d.js',
'5 svelte',
'6 electron',
'7 node:fs',
'8 vitest',
'9 svelte/store',
'9 ./i.cjs',
]);
});
test("un argument qui n'est pas une chaîne littérale se relève comme calculé, sans spécificateur", () => {
const texte = [
'const a = require(nom);',
'const b = await import(`./${module}.js`);',
"const c = require('./' + nom);",
'const d = await import( cible );',
].join('\n');
assert.deepEqual(lus(texte), [
'1 calculé nom',
'2 calculé `./${module}.js`',
"3 calculé './' + nom",
'4 calculé cible',
]);
});
test("ni une chaîne qui finit sur « import », ni la règle @import d'une feuille de style ne sont des chargements", () => {
const texte = [
"spawnSync(process.execPath, ['--import', 'chargeur.js']);",
'<style>',
" @import 'jetons.css';",
'</style>',
"const mot = 'import';",
].join('\n');
assert.deepEqual(lus(texte), []);
});
});

View file

@ -0,0 +1,109 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le paquet livré n'emporte rien de l'outillage d'épreuve (§ 14.9, § 14.15) :
// ni fichier de test, ni le lanceur, ni donnée d'épreuve, ni paquet d'un outil
// d'épreuve. Le test construit la page par l'API de Vite, avec la
// configuration et le mode de la livraison, dans un répertoire temporaire ;
// il relit les modules que contient le paquet, les noms d'origine de chaque
// actif, puis le chemin et le contenu de chaque fichier écrit.
import assert from 'node:assert/strict';
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { build } from 'vite';
import { describe, test } from './lanceur.js';
const RACINE = fileURLToPath(new URL('..', import.meta.url));
// Paquets qui ne servent qu'aux épreuves.
const OUTILS_EPREUVE =
/[\\/]node_modules[\\/](?:vitest|@vitest|playwright|playwright-core|fast-check)[\\/]/;
// Vrai quand un module vient de l'outillage d'épreuve : l'arbre test/
// (lanceur, données d'épreuve), un fichier *.test.js rangé à côté de son
// module, un paquet d'épreuve, ou un module de Node que Vite remplace pour le
// navigateur, comme node:assert.
function moduleDEpreuve(id) {
return (
id.startsWith(join(RACINE, 'test') + sep) ||
/\.test\.[cm]?js(?:\?|$)/.test(id) ||
OUTILS_EPREUVE.test(id) ||
id.includes('__vite-browser-external')
);
}
// Traces de l'outillage dans le chemin ou le contenu d'un fichier écrit. Les
// motifs portent l'extension : le domaine emploie le mot « lanceur » (lanceur
// portable, § 8.6), et un code minifié peut écrire « x.test.call ».
const TRACES_EPREUVE = [/vitest/i, /node:assert/, /lanceur\.js/, /\.test\.[cm]?js/];
// Construit la page comme la livraison : vite.config.js, mode livraison,
// NODE_ENV=production. Vitest pose NODE_ENV=test, dont Vite déduirait une
// construction de développement — composants compilés en mode dev, paquets
// résolus sous la condition « development ».
async function construireLivraison(sortie) {
const nodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
try {
return await build({
root: RACINE,
configFile: join(RACINE, 'vite.config.js'),
mode: 'livraison',
logLevel: 'error',
// Sans incrustation, chaque actif sort en fichier et garde ses noms
// d'origine ; incrusté en data:, il ne laisserait aucune trace lisible.
build: { outDir: sortie, emptyOutDir: true, assetsInlineLimit: 0 },
});
} finally {
if (nodeEnv === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = nodeEnv;
}
}
describe('construction', () => {
test("le paquet livré n'emporte aucun fichier ni module d'épreuve (§ 14.9)", async () => {
const sortie = mkdtempSync(join(tmpdir(), 'construction-'));
try {
const resultat = await construireLivraison(sortie);
const sorties = (Array.isArray(resultat) ? resultat : [resultat]).flatMap(
(r) => r.output,
);
const modules = sorties
.filter((s) => s.type === 'chunk')
.flatMap((s) => s.moduleIds);
assert.ok(
modules.some((id) => id.startsWith(join(RACINE, 'src') + sep)),
`aucun module de src/ parmi ${modules.length} modules examinés`,
);
assert.deepEqual(modules.filter(moduleDEpreuve), []);
// Un actif n'est pas un module : moduleIds ne le voit pas. Une donnée
// jointe par new URL(…, import.meta.url) n'apparaît que là.
const actifs = sorties
.filter((s) => s.type === 'asset')
.flatMap((s) => (s.originalFileNames ?? []).map((n) => resolve(RACINE, n)));
assert.ok(actifs.length > 0, `aucun actif d'origine connue parmi ${sorties.length} sorties`);
assert.deepEqual(actifs.filter(moduleDEpreuve), []);
const fichiers = readdirSync(sortie, { recursive: true, withFileTypes: true })
.filter((e) => e.isFile())
.map((e) => join(e.parentPath, e.name));
assert.ok(
fichiers.some((f) => f.endsWith('.js')),
`aucun script parmi ${fichiers.length} fichiers écrits`,
);
const traces = fichiers.flatMap((f) => {
const nom = relative(sortie, f);
const contenu = readFileSync(f, 'utf8');
return TRACES_EPREUVE.filter((m) => m.test(nom) || m.test(contenu)).map(
(m) => `${nom} : ${m}`,
);
});
assert.deepEqual(traces, []);
} finally {
rmSync(sortie, { recursive: true, force: true });
}
});
});

148
test/coquille.test.js Normal file
View file

@ -0,0 +1,148 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La coquille Electron (§ 13.1) : la fenêtre qu'ouvre le processus principal,
// la page qu'elle charge, le pont étroit que le préchargement expose, et le
// trajet du dossier publié par le lanceur portable jusqu'à la page (§ 8.6,
// point 1).
//
// electron/main.js s'exécute sous Node, dans un processus à part, contre
// test/electron_factice.js, qui consigne ce que la coquille demande à
// Electron puis rend son journal en JSON. Ce qui s'éprouve ici est ce que la
// coquille demande ; qu'Electron l'applique relève d'une épreuve dans un vrai
// Electron.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { describe, test } from './lanceur.js';
const RACINE = fileURLToPath(new URL('..', import.meta.url));
const FACTICE = pathToFileURL(join(RACINE, 'test', 'electron_factice.js')).href;
const PRINCIPAL = join(RACINE, 'electron', 'main.js');
const PRECHARGEMENT = join(RACINE, 'electron', 'preload.cjs');
const CANAL = 'gtt:dossier-executable';
// Délai de l'exécution de la coquille. Le factice ne joue la page qu'une
// fois le processus sans rien à faire ; un minuteur ou un écouteur laissé
// actif l'en empêche, et l'exécution ne finit jamais. Le délai reste sous
// celui d'une épreuve de Vitest, 5 s, qui n'interrompt pas un appel
// synchrone : l'épreuve échoue en le disant, au lieu de figer la série. Le
// délai écoulé, le processus reçoit KILL, qu'aucun gestionnaire de signal
// ne retient.
const DELAI_MS = 4_000;
// Exécute la coquille contre le factice et rend son journal. Le répertoire
// courant est un dossier vide hors du projet : dans l'exécutable, il n'est
// jamais l'intérieur d'app.asar, si bien qu'un chemin résolu contre lui ne
// désigne ni la page ni le préchargement.
function executerLaCoquille() {
const environnement = { ...process.env };
delete environnement.PORTABLE_EXECUTABLE_DIR;
const ailleurs = mkdtempSync(join(tmpdir(), 'coquille-'));
try {
const resultat = spawnSync(process.execPath, ['--import', FACTICE, PRINCIPAL], {
cwd: ailleurs,
encoding: 'utf8',
env: environnement,
timeout: DELAI_MS,
killSignal: 'SIGKILL',
});
assert.notEqual(
resultat.error?.code,
'ETIMEDOUT',
`la coquille ne s'arrête pas en ${DELAI_MS} ms : un minuteur ou un écouteur la tient en vie`,
);
assert.equal(resultat.status, 0, `la coquille ne s'exécute pas :\n${resultat.stderr}`);
return JSON.parse(resultat.stdout);
} finally {
rmSync(ailleurs, { recursive: true, force: true });
}
}
// L'exécution est partagée par les épreuves : le processus ne se lance
// qu'une fois, et son échec se rend à chacune sans relancer.
let execution;
function journalDeLaCoquille() {
if (execution === undefined) {
try {
execution = { journal: executerLaCoquille() };
} catch (erreur) {
execution = { erreur };
}
}
if (execution.erreur !== undefined) throw execution.erreur;
return execution.journal;
}
describe('coquille : processus principal', () => {
test("une seule fenêtre, 1366 × 768 : page isolée, sans Node, en bac à sable, préchargée par le pont", () => {
const { fenetres } = journalDeLaCoquille();
assert.equal(fenetres.length, 1);
const [{ width, height, webPreferences }] = fenetres;
assert.deepEqual({ width, height }, { width: 1366, height: 768 });
assert.deepEqual(webPreferences, {
preload: PRECHARGEMENT,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
});
assert.ok(existsSync(webPreferences.preload), `${webPreferences.preload} absent`);
});
test("aucun menu d'application : le menu par défaut d'Electron, en anglais, est retiré avant la première fenêtre (§ 2)", () => {
const { menus, sequence } = journalDeLaCoquille();
assert.deepEqual(menus, [null]);
const retrait = sequence.indexOf('Menu.setApplicationMenu');
const fenetre = sequence.indexOf('new BrowserWindow');
assert.ok(retrait !== -1 && fenetre !== -1 && retrait < fenetre, `ordre des appels : ${sequence.join(', ')}`);
});
test('la fenêtre charge la page que Vite construit dans www/', () => {
assert.deepEqual(journalDeLaCoquille().chargements, [
{ loadFile: join(RACINE, 'www', 'index.html') },
]);
});
test(`un seul canal IPC, ${CANAL}, posé avant le chargement de la page`, () => {
const { canaux, sequence } = journalDeLaCoquille();
assert.deepEqual(canaux, [CANAL]);
const pose = sequence.indexOf(`ipcMain.handle:${CANAL}`);
const charge = sequence.indexOf('loadFile');
assert.ok(pose !== -1 && charge !== -1 && pose < charge, `ordre des appels : ${sequence.join(', ')}`);
});
test("la page n'ouvre aucune fenêtre et ne navigue nulle part", () => {
const { ouvertures, navigations } = journalDeLaCoquille();
assert.deepEqual(ouvertures, [{ action: 'deny' }]);
assert.ok(navigations.length > 0, 'aucun écouteur de will-navigate');
assert.deepEqual(navigations, navigations.map(() => true));
});
});
describe('coquille : préchargement', () => {
test("n'expose que window.gtt, et ne requiert qu'electron", () => {
const { prechargement } = journalDeLaCoquille();
assert.equal(prechargement.erreur, undefined, prechargement.erreur);
assert.deepEqual(prechargement.requis, ['electron']);
assert.deepEqual(prechargement.exposes, [
{ cle: 'gtt', membres: ['dossierExecutable', 'plateforme'] },
]);
assert.equal(prechargement.plateforme, 'electron');
});
test(`gtt.dossierExecutable() demande à chaque appel, par ${CANAL}, le dossier publié par le lanceur portable, null sans lui`, () => {
const { prechargement, invocations } = journalDeLaCoquille();
assert.equal(prechargement.erreur, undefined, prechargement.erreur);
assert.deepEqual(prechargement.reponses, {
publie: { valeur: 'E:\\soirees' },
absent: { valeur: null },
vide: { valeur: null },
});
// Le préchargement voit lui aussi process.env : seules les invocations
// montrent que la réponse vient du processus principal, une par appel.
assert.deepEqual(invocations, [CANAL, CANAL, CANAL]);
});
});

238
test/electron_factice.js Normal file
View file

@ -0,0 +1,238 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Electron factice : la coquille s'éprouve sous Node, sans Electron.
//
// node --import <ce module> electron/main.js
//
// Chargé avant electron/main.js, ce module fait résoudre le spécificateur
// « electron » vers lui-même : le processus principal s'exécute contre les
// fausses API exportées ici, qui consignent chaque appel. Quand le processus
// n'a plus rien à faire, le module joue le rôle de la page : il exécute
// electron/preload.cjs dans une fonction qui reçoit require, module et
// exports, appelle le pont exposé et les gestionnaires posés par le processus
// principal, puis imprime le journal en JSON sur la sortie standard.
//
// Seul require est restreint, à electron, comme dans un rendu en bac à
// sable. Les globales de Node du processus d'épreuve, dont process et Buffer,
// restent visibles du préchargement, et un vrai préchargement en bac à sable
// lit lui aussi process.env. Un pont qui lirait lui-même la variable
// répondrait donc juste : le journal des invocations, et non l'absence de
// process, montre que chaque réponse vient du processus principal.
//
// Les exports sont la surface d'Electron que la coquille a le droit
// d'employer : un import absent d'ici fait échouer le chargement de
// electron/main.js. Ils refusent ce qu'Electron refuse et qu'une erreur de
// la coquille provoquerait : une fenêtre créée avant que l'application soit
// prête, un second gestionnaire pour un même canal, un appel à un canal sans
// gestionnaire.
import { readFileSync } from 'node:fs';
import { registerHooks } from 'node:module';
import { fileURLToPath } from 'node:url';
import { compileFunction } from 'node:vm';
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === 'electron') return { url: import.meta.url, shortCircuit: true };
return nextResolve(specifier, context);
},
});
const PRECHARGEMENT = fileURLToPath(new URL('../electron/preload.cjs', import.meta.url));
// Valeur de PORTABLE_EXECUTABLE_DIR quand le lanceur portable la publie.
const DOSSIER_PUBLIE = 'E:\\soirees';
const journal = {
// Appels qui comptent pour l'ordre, dans l'ordre où ils arrivent.
sequence: [],
// Options de chaque new BrowserWindow.
fenetres: [],
// Argument de chaque Menu.setApplicationMenu.
menus: [],
// { loadFile: chemin } ou { loadURL: adresse }, par appel.
chargements: [],
// Canal de chaque ipcMain.handle ou ipcMain.on.
canaux: [],
// Canal de chaque ipcRenderer.invoke du préchargement, dans l'ordre des
// appels.
invocations: [],
// Réponse du gestionnaire d'ouverture de chaque fenêtre à une adresse
// externe.
ouvertures: [],
// Pour chaque écouteur de will-navigate : la navigation est-elle annulée ?
navigations: [],
// Ce que fait le préchargement : modules requis, objets exposés, et la
// réponse du pont selon PORTABLE_EXECUTABLE_DIR.
prechargement: null,
};
const gestionnaires = new Map();
const pageDeFenetre = [];
// L'application n'est jamais prête pendant l'évaluation du module principal,
// ni dans les microtâches qu'elle lance : whenReady() la rend prête à la
// phase setImmediate qui suit, puis résout, la même promesse à chaque appel.
// Sans appel à whenReady(), elle ne l'est jamais.
let pret = false;
let quandPret;
export const app = {
whenReady() {
journal.sequence.push('app.whenReady');
quandPret ??= new Promise((resoudre) => {
setImmediate(() => {
pret = true;
resoudre();
});
});
return quandPret;
},
on(evenement) {
journal.sequence.push(`app.on:${evenement}`);
},
quit() {
journal.sequence.push('app.quit');
},
};
export class BrowserWindow {
constructor(options) {
if (!pret) throw new Error('Cannot create BrowserWindow before app is ready');
journal.sequence.push('new BrowserWindow');
journal.fenetres.push(options);
const page = { ouverture: null, ecouteurs: [] };
pageDeFenetre.push(page);
this.webContents = {
setWindowOpenHandler(gestionnaire) {
page.ouverture = gestionnaire;
},
on(evenement, ecouteur) {
page.ecouteurs.push({ evenement, ecouteur });
},
};
}
loadFile(chemin) {
journal.sequence.push('loadFile');
journal.chargements.push({ loadFile: chemin });
return Promise.resolve();
}
loadURL(adresse) {
journal.sequence.push('loadURL');
journal.chargements.push({ loadURL: adresse });
return Promise.resolve();
}
}
export const Menu = {
setApplicationMenu(menu) {
journal.sequence.push('Menu.setApplicationMenu');
journal.menus.push(menu);
},
};
export const ipcMain = {
handle(canal, gestionnaire) {
journal.sequence.push(`ipcMain.handle:${canal}`);
journal.canaux.push(canal);
if (gestionnaires.has(canal)) {
throw new Error(`Attempted to register a second handler for '${canal}'`);
}
gestionnaires.set(canal, gestionnaire);
},
on(canal) {
journal.sequence.push(`ipcMain.on:${canal}`);
journal.canaux.push(canal);
},
};
// Côté page : ce que require('electron') rend au préchargement. invoke se
// consigne, atteint le gestionnaire posé par le processus principal pour ce
// canal, et rejette comme Electron quand il n'y en a pas.
function electronDuRendu(exposes) {
return {
contextBridge: {
exposeInMainWorld(cle, api) {
exposes.push({ cle, api });
},
},
ipcRenderer: {
async invoke(canal, ...arguments_) {
journal.invocations.push(canal);
const gestionnaire = gestionnaires.get(canal);
if (gestionnaire === undefined) throw new Error(`No handler registered for '${canal}'`);
return gestionnaire({ sender: null }, ...arguments_);
},
},
};
}
// Réponse du pont pour une valeur de PORTABLE_EXECUTABLE_DIR, undefined
// valant variable absente ; une erreur se consigne par son message.
async function reponseDuPont(api, valeur) {
if (valeur === undefined) delete process.env.PORTABLE_EXECUTABLE_DIR;
else process.env.PORTABLE_EXECUTABLE_DIR = valeur;
try {
return { valeur: await api.dossierExecutable() };
} catch (erreur) {
return { erreur: erreur.message };
}
}
async function jouerLePrechargement() {
const requis = [];
const exposes = [];
const electron = electronDuRendu(exposes);
const requerir = (nom) => {
requis.push(nom);
if (nom === 'electron') return electron;
throw new Error(`module « ${nom} » indisponible dans un préchargement en bac à sable`);
};
const module = { exports: {} };
compileFunction(readFileSync(PRECHARGEMENT, 'utf8'), ['require', 'module', 'exports'], {
filename: PRECHARGEMENT,
})(requerir, module, module.exports);
const gtt = exposes.find(({ cle }) => cle === 'gtt')?.api;
return {
requis,
exposes: exposes.map(({ cle, api }) => ({ cle, membres: Object.keys(api).sort() })),
plateforme: gtt?.plateforme ?? null,
reponses:
typeof gtt?.dossierExecutable === 'function'
? {
publie: await reponseDuPont(gtt, DOSSIER_PUBLIE),
absent: await reponseDuPont(gtt, undefined),
vide: await reponseDuPont(gtt, ''),
}
: null,
};
}
async function jouerLaPage() {
const externe = 'https://exemple.invalid/';
for (const page of pageDeFenetre) {
journal.ouvertures.push(page.ouverture === null ? null : page.ouverture({ url: externe }));
for (const { evenement, ecouteur } of page.ecouteurs) {
if (evenement !== 'will-navigate') continue;
let annulee = false;
ecouteur({ url: externe, preventDefault: () => (annulee = true) }, externe);
journal.navigations.push(annulee);
}
}
try {
journal.prechargement = await jouerLePrechargement();
} catch (erreur) {
journal.prechargement = { erreur: erreur.message };
}
process.stdout.write(`${JSON.stringify(journal)}\n`);
}
process.once('beforeExit', () => {
jouerLaPage().catch((erreur) => {
process.stderr.write(`${erreur.stack}\n`);
process.exitCode = 1;
});
});

123
test/fils.test.js Normal file
View file

@ -0,0 +1,123 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Sur une petite machine, les séries node et node-long se jouent en threads
// (test/machine.js). Un thread de Node refuse ce qui changerait le processus
// entier — process.chdir, process.umask avec un masque, les changements
// d'identité — là où un processus complet l'accepte : une épreuve qui s'en
// servirait passerait sur une grande machine et échouerait sur une petite.
// La garde les refuse dans tout le code que ces séries exécutent : les
// épreuves de src/ et de scripts/, les modules sous test/, et les scripts
// qu'elles importent. L'environnement, lui, est copié dans chaque thread :
// une épreuve qui écrit process.env ne trouble pas sa voisine, et la garde ne
// le refuse pas.
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, test } from './lanceur.js';
const RACINE = fileURLToPath(new URL('..', import.meta.url));
// Appels qu'un thread de Node refuse. Un motif exige la parenthèse de
// l'appel : une phrase qui nomme process.chdir n'en est pas un. umask sans
// argument lit le masque, ce qu'un thread accepte.
const REFUSES_DANS_UN_THREAD = [
['process.chdir', /\bprocess\s*\??\.\s*chdir\s*(?:\?\.)?\s*\(/g],
['process.umask(masque)', /\bprocess\s*\??\.\s*umask\s*(?:\?\.)?\s*\(\s*[^)\s]/g],
['process.set…id', /\bprocess\s*\??\.\s*(?:set(?:e?[ug]id|groups)|initgroups)\s*(?:\?\.)?\s*\(/g],
];
// Ce fichier nomme ces appels dans ses motifs et dans ses données d'épreuve :
// il est hors de son propre balayage.
const CE_FICHIER = fileURLToPath(import.meta.url);
// Fichiers JavaScript que les séries node exécutent : les épreuves de src/ et
// de scripts/, hors épreuves du navigateur, tout module sous test/, et les
// scripts de scripts/, que des épreuves importent.
function fichiersExecutes(racine) {
const sous = (dossier) => {
try {
return readdirSync(join(racine, dossier), { recursive: true, withFileTypes: true })
.filter((e) => e.isFile() && /\.[cm]?js$/.test(e.name))
.map((e) => join(e.parentPath, e.name));
} catch {
return [];
}
};
return [
...sous('src').filter((f) => /\.test\.[cm]?js$/.test(f) && !/\.navigateur\.test\.[cm]?js$/.test(f)),
...sous('scripts'),
...sous('test').filter((f) => !f.includes(`${join('test', 'fixtures')}`) && f !== CE_FICHIER),
].sort();
}
// Relevé « fichier:ligne appel » de ces appels, par fichier puis par ligne.
// Lève quand aucun fichier n'est examiné (§ 14.2).
function releverAppelsRefuses(racine) {
const fichiers = fichiersExecutes(racine);
assert.ok(fichiers.length > 0, 'aucun fichier examiné');
return fichiers.flatMap((fichier) => {
const texte = readFileSync(fichier, 'utf8');
return REFUSES_DANS_UN_THREAD.flatMap(([appel, motif]) =>
[...texte.matchAll(motif)].map(({ index }) => ({ ligne: texte.slice(0, index).split('\n').length, appel })),
)
.sort((a, b) => a.ligne - b.ligne)
.map(({ ligne, appel }) => `${relative(racine, fichier)}:${ligne} ${appel}`);
});
}
// Écrit les fichiers donnés dans une racine temporaire, rend le relevé, et
// efface la racine.
function avecArbre(fichiers) {
const racine = mkdtempSync(join(tmpdir(), 'fils-'));
try {
for (const [chemin, texte] of Object.entries(fichiers)) {
mkdirSync(dirname(join(racine, chemin)), { recursive: true });
writeFileSync(join(racine, chemin), texte);
}
return releverAppelsRefuses(racine);
} finally {
rmSync(racine, { recursive: true, force: true });
}
}
describe('fils : ce qu’un thread refuse, hors des séries node (test/machine.js)', () => {
test('aucune épreuve, aucun module sous test/ ni aucun script n’appelle ce qu’un thread refuse', () => {
assert.deepEqual(releverAppelsRefuses(RACINE), []);
});
test('la garde relève chaque appel par sa ligne, dans les épreuves, les modules sous test/ et les scripts, et pas ses voisins', () => {
const fichiers = {
'src/moteur/a.test.js': [
"process.chdir('/tmp');",
'process.umask(0o022);',
'process.setuid(1000);',
'process?.chdir?.(ici);',
].join('\n'),
'scripts/outil.js': 'process.setgroups([]);\n',
'test/aide.js': 'process.initgroups("x", 1);\n',
'src/moteur/voisins.test.js': [
'const ici = process.cwd();',
'const masque = process.umask();',
"process.env.SONDE = 'copie par thread';",
'// process.chdir changerait le processus entier.',
].join('\n'),
'src/moteur/module.js': "process.chdir('/tmp');\n",
'src/interface/App.navigateur.test.js': "process.chdir('/tmp');\n",
};
assert.deepEqual(avecArbre(fichiers), [
'scripts/outil.js:1 process.set…id',
'src/moteur/a.test.js:1 process.chdir',
'src/moteur/a.test.js:2 process.umask(masque)',
'src/moteur/a.test.js:3 process.set…id',
'src/moteur/a.test.js:4 process.chdir',
'test/aide.js:1 process.set…id',
]);
});
test('un arbre sans fichier exécuté par ces séries fait échouer la garde', () => {
assert.throws(() => avecArbre({ 'src/moteur/module.js': 'export const a = 1;\n' }), /aucun fichier examiné/);
});
});

7
test/lanceur.js Normal file
View file

@ -0,0 +1,7 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Point d'import unique des primitives de test. Remplacer cette ligne par
// « export { test, describe } from 'node:test'; » fait tourner la série node
// sous « node --test » sans toucher aux fichiers de test (§ 14.8).
export { test, describe } from 'vitest';

146
test/licence.test.js Normal file
View file

@ -0,0 +1,146 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Chaque fichier source du projet s'ouvre sur l'en-tête de licence : la ligne
// du titulaire, puis celle de la licence, après un éventuel shebang ou la
// déclaration <!doctype html>. Les deux lignes sont des commentaires du
// langage du fichier ; le texte ne varie pas. package.json déclare la même
// licence et le même titulaire, que reprennent les outils d'emballage.
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import { extname, join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, test } from './lanceur.js';
const RACINE = fileURLToPath(new URL('..', import.meta.url));
const TITULAIRE = /^© \d{4} TechnoLibre \(http:\/\/www\.technolibre\.ca\)$/;
const LICENCE = 'License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)';
// Marqueurs de commentaire, par extension de fichier source. Un commentaire
// de ligne n'a pas de fermant.
const COMMENTAIRE = new Map([
['.js', { ouvrant: '//', fermant: null }],
['.cjs', { ouvrant: '//', fermant: null }],
['.mjs', { ouvrant: '//', fermant: null }],
['.sh', { ouvrant: '#', fermant: null }],
['.svelte', { ouvrant: '<!--', fermant: '-->' }],
['.html', { ouvrant: '<!--', fermant: '-->' }],
['.css', { ouvrant: '/*', fermant: '*/' }],
]);
// Périmètre : les fichiers de la racine et les arbres des sources, des
// épreuves, des scripts et de la coquille. Les dépendances et les sorties de
// construction n'y entrent pas, ni les données d'épreuve, qui sont des
// données et non des sources. Le séparateur final borne l'exclusion au
// répertoire test/fixtures/ : un module test/fixtures.js reste une source.
const ARBRES = ['src', 'test', 'scripts', 'electron'];
const DONNEES_EPREUVE = join(RACINE, 'test', 'fixtures') + sep;
function fichiersSources() {
const chemins = readdirSync(RACINE, { withFileTypes: true })
.filter((e) => e.isFile())
.map((e) => join(RACINE, e.name));
for (const arbre of ARBRES) {
const dossier = join(RACINE, arbre);
let entrees;
try {
entrees = readdirSync(dossier, { withFileTypes: true, recursive: true });
} catch (erreur) {
if (erreur.code === 'ENOENT') continue;
throw erreur;
}
for (const e of entrees) {
if (e.isFile()) chemins.push(join(e.parentPath, e.name));
}
}
return chemins
.filter((chemin) => COMMENTAIRE.has(extname(chemin)))
.filter((chemin) => !chemin.startsWith(DONNEES_EPREUVE))
.sort();
}
// Vrai quand les deux lignes sont des commentaires, et que le commentaire ne
// se prolonge pas au-delà de la seconde. Un commentaire de ligne ouvre
// chacune d'elles. Un commentaire de bloc court sur les deux — ouvert sur la
// première sans s'y fermer, fermé en fin de seconde — ou s'ouvre et se ferme
// sur chacune. Une seconde ligne hors commentaire se lit comme du code : un
// texte affiché avant le bandeau dans un composant, une règle de style
// invalidée dans une feuille, une commande dans un script.
function enCommentaire(premiere, seconde, { ouvrant, fermant }) {
const suite = seconde.trim();
if (!premiere.startsWith(ouvrant)) return false;
if (fermant === null) return suite.startsWith(ouvrant);
const courant = !premiere.includes(fermant) && suite.endsWith(fermant);
const repete =
premiere.trimEnd().endsWith(fermant) &&
suite.startsWith(ouvrant) &&
suite.endsWith(fermant);
return courant || repete;
}
// Vrai quand le contenu s'ouvre sur l'en-tête, après un éventuel shebang ou
// la déclaration <!doctype html> : deux lignes de commentaire du langage de
// l'extension, dont le texte, débarrassé des marqueurs, est celui de
// l'en-tête.
function porteLEnTete(contenu, extension) {
let lignes = contenu.split('\n');
if (lignes[0].startsWith('#!')) lignes = lignes.slice(1);
if (/^<!doctype html>$/i.test(lignes[0])) lignes = lignes.slice(1);
const [premiere = '', seconde = ''] = lignes;
const texte = (ligne) =>
ligne
.replace(/^\s*(?:\/\/|#|<!--|\/\*|\*)?\s*/, '')
.replace(/\s*(?:-->|\*\/)?\s*$/, '');
return (
enCommentaire(premiere, seconde, COMMENTAIRE.get(extension)) &&
TITULAIRE.test(texte(premiere)) &&
texte(seconde) === LICENCE
);
}
describe('licence', () => {
test("chaque fichier source s'ouvre sur l'en-tête de licence", () => {
const fichiers = fichiersSources();
assert.ok(fichiers.length > 0, 'aucun fichier source examiné');
const sansEnTete = fichiers
.filter((chemin) => !porteLEnTete(readFileSync(chemin, 'utf8'), extname(chemin)))
.map((chemin) => relative(RACINE, chemin));
assert.deepEqual(sansEnTete, []);
});
test("l'en-tête tient en deux lignes de commentaire du langage du fichier", () => {
const titulaire = '© 2026 TechnoLibre (http://www.technolibre.ca)';
// [extension, contenu, admis] : les formes admises ; puis des secondes
// lignes laissées hors commentaire, que le langage lirait comme du code,
// et un commentaire qui se prolonge au-delà de la seconde ligne.
const cas = [
['.js', `// ${titulaire}\n// ${LICENCE}\n`, true],
['.sh', `#!/bin/sh\n# ${titulaire}\n# ${LICENCE}\n`, true],
['.svelte', `<!-- ${titulaire}\n ${LICENCE} -->\n`, true],
['.svelte', `<!-- ${titulaire} -->\n<!-- ${LICENCE} -->\n`, true],
['.html', `<!doctype html>\n<!-- ${titulaire}\n ${LICENCE} -->\n`, true],
['.css', `/* ${titulaire}\n ${LICENCE} */\n`, true],
['.css', `/* ${titulaire} */\n/* ${LICENCE} */\n`, true],
['.js', `// ${titulaire}\n${LICENCE}\n`, false],
['.sh', `#!/bin/sh\n# ${titulaire}\n${LICENCE}\n`, false],
['.svelte', `<!-- ${titulaire} -->\n${LICENCE}\n`, false],
['.html', `<!doctype html>\n<!-- ${titulaire} -->\n${LICENCE}\n`, false],
['.html', `<!-- ${titulaire}\n ${LICENCE}\n-->\n`, false],
['.css', `/* ${titulaire} */\n${LICENCE}\n`, false],
['.css', `/* ${titulaire} */\n * ${LICENCE} */\n`, false],
];
const ecarts = cas
.filter(([extension, contenu, admis]) => porteLEnTete(contenu, extension) !== admis)
.map(([extension, contenu, admis]) =>
`${extension} ${admis ? 'refusé' : 'admis'} : ${JSON.stringify(contenu)}`,
);
assert.deepEqual(ecarts, []);
});
test('package.json déclare la licence et le titulaire', () => {
const paquet = JSON.parse(readFileSync(join(RACINE, 'package.json'), 'utf8'));
assert.equal(paquet.license, 'AGPL-3.0-or-later');
assert.equal(paquet.author, 'TechnoLibre (http://www.technolibre.ca)');
});
});

221
test/livrable.test.js Normal file
View file

@ -0,0 +1,221 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le livrable tel qu'electron-builder.config.cjs le décrit : son nom (§ 18.2),
// sa forme (§ 16), ce qu'il emporte (§ 14.15) et l'identité inscrite dans sa
// ressource de version ; et le nom de l'application, un seul pour Electron,
// pour Capacitor et pour le titre de la fenêtre (§ 18.6). La configuration se
// charge ici comme electron-builder la charge, par require.
import assert from 'node:assert/strict';
import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { createRequire, isBuiltin } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { deriver } from '../scripts/version.js';
import { titreAvecVersion } from '../src/interface/libelles.js';
import { VERSION } from '../src/version.genere.js';
import { chargements } from './chargements.js';
import { describe, test } from './lanceur.js';
import { versionVoisine } from './version_voisine.js';
const RACINE = fileURLToPath(new URL('..', import.meta.url));
const CONFIGURATION = 'electron-builder.config.cjs';
const lireJson = (racine, chemin) => JSON.parse(readFileSync(join(racine, chemin), 'utf8'));
const charger = (racine) => {
const chemin = join(racine, CONFIGURATION);
return createRequire(chemin)(chemin);
};
// Fichiers d'electron/ sous une racine, à toute profondeur, en chemins
// absolus.
const fichiersDElectron = (racine) =>
readdirSync(join(racine, 'electron'), { recursive: true, withFileTypes: true })
.filter((entree) => entree.isFile())
.map((entree) => join(entree.parentPath, entree.name));
// Raison de refuser le chargement que demande un fichier du répertoire
// electron donné, ou null quand il reste dans le paquet : electron, un module
// de Node hors du lanceur d'épreuves, ou un fichier de ce répertoire. Le
// paquet n'emporte ni node_modules ni rien de test/ : un chargement qui en
// sort échoue au démarrage de l'exécutable. Un chargement calculé ne se
// vérifie pas d'ici.
function raisonDeRefus(electron, { fichier, specificateur, argument }) {
if (specificateur === undefined) return `argument calculé, ${argument} : il ne se vérifie pas`;
if (/^(?:vitest|node:test)(?:\/|$)|^@vitest\//.test(specificateur)) return "lanceur d'épreuves";
if (specificateur === 'electron' || isBuiltin(specificateur)) return null;
if (/^\.{1,2}(?:\/|$)/.test(specificateur)) {
return resolve(dirname(fichier), specificateur).startsWith(`${electron}${sep}`) ? null : "hors d'electron/";
}
return "hors du paquet, qui n'emporte aucun node_modules";
}
// Chargements refusés des fichiers d'electron/ sous une racine, par fichier
// puis dans l'ordre du texte : chacun est { fichier, specificateur, raison }
// ou { fichier, argument, raison }, fichier relatif à la racine. Lève quand
// electron/ n'a aucun fichier, ou quand ses fichiers ne demandent aucun
// chargement.
function refusDElectron(racine) {
const electron = join(racine, 'electron');
const fichiers = fichiersDElectron(racine).sort();
assert.ok(fichiers.length > 0, 'electron/ ne contient aucun fichier');
const demandes = fichiers.flatMap((fichier) =>
chargements(readFileSync(fichier, 'utf8')).map(({ specificateur, argument }) =>
specificateur === undefined ? { fichier, argument } : { fichier, specificateur },
),
);
assert.ok(demandes.length > 0, 'aucun chargement relevé dans electron/');
return demandes
.map((demande) => ({ ...demande, fichier: relative(racine, demande.fichier), raison: raisonDeRefus(electron, demande) }))
.filter(({ raison }) => raison !== null);
}
describe('livrable : nom', () => {
test('le nom est celui que deriver calcule depuis version.json (§ 18.2)', () => {
const { version } = lireJson(RACINE, 'version.json');
assert.equal(charger(RACINE).artifactName, deriver(version).nomFichier);
});
test("le nom suit version.json : la configuration ne l'écrit pas elle-même", () => {
// Un arbre où version.json porte la version voisine. La configuration et
// scripts/version.js y sont recopiés tels quels.
const { version } = lireJson(RACINE, 'version.json');
const autre = versionVoisine(version);
const arbre = mkdtempSync(join(tmpdir(), 'livrable-'));
try {
for (const chemin of [CONFIGURATION, 'scripts/version.js', 'package.json', 'capacitor.config.json']) {
mkdirSync(dirname(join(arbre, chemin)), { recursive: true });
copyFileSync(join(RACINE, chemin), join(arbre, chemin));
}
writeFileSync(join(arbre, 'version.json'), `${JSON.stringify({ version: autre })}\n`);
const nom = charger(arbre).artifactName;
assert.equal(nom, deriver(autre).nomFichier);
assert.notEqual(nom, deriver(version).nomFichier);
} finally {
rmSync(arbre, { recursive: true, force: true });
}
});
});
describe('livrable : forme et contenu', () => {
test('un exécutable portable à fichier unique, pour Windows x64 : jamais un installateur (§ 16)', () => {
assert.deepEqual(charger(RACINE).win, { target: [{ target: 'portable', arch: ['x64'] }] });
});
test("sortie dans dist-electron/ ; le paquet n'emporte que la page, la coquille et package.json", () => {
const configuration = charger(RACINE);
assert.deepEqual(configuration.directories, { output: 'dist-electron' });
assert.deepEqual(configuration.files, ['www/**', 'electron/**', 'package.json']);
});
test("la configuration n'a pas d'autre clé que celles que ces épreuves examinent (§ 14.15, § 18.1)", () => {
// Une clé de plus change ce que le paquet emporte (extraResources,
// extraFiles, asar) ou la version qu'il porte (buildVersion, buildNumber),
// et aucune épreuve ne la lirait : elle échoue ici jusqu'à sa revue. Les
// épreuves de chaque clé comparent sa valeur entière, si bien qu'une clé
// ajoutée plus bas, sous win ou directories, échoue aussi.
assert.deepEqual(Object.keys(charger(RACINE)).sort(), [
'appId',
'artifactName',
'copyright',
'directories',
'files',
'productName',
'win',
]);
});
test("electron/, que le paquet emporte en entier, ne contient aucune épreuve (§ 14.15)", () => {
const fichiers = fichiersDElectron(RACINE);
assert.ok(fichiers.length > 0, 'electron/ ne contient aucun fichier');
assert.deepEqual(fichiers.filter((chemin) => /\.test\.[cm]?js$/.test(chemin)), []);
});
test("electron/ ne charge qu'electron, Node et ses propres fichiers : ni épreuve, ni lanceur, ni dépendance (§ 14.9)", () => {
assert.deepEqual(refusDElectron(RACINE), []);
});
test("la garde d'electron/ relève chaque chargement refusé, import à commentaire entre ses accolades compris, et admet electron, Node et les fichiers d'electron/", () => {
const arbre = mkdtempSync(join(tmpdir(), 'livrable-'));
try {
mkdirSync(join(arbre, 'electron'));
writeFileSync(join(arbre, 'electron', 'main.js'), [
"import { app } from 'electron';",
"import { join } from 'node:path';",
'import {',
" // l'API des épreuves",
' test,',
"} from 'vitest';",
'import {',
" // l'aide d'à côté",
' aide,',
"} from '../test/aide.js';",
"import { voisin } from './voisin.js';",
"const prechargement = require('./preload.cjs');",
'const module = require(chemin);',
"const { mount } = await import('svelte');",
].join('\n'));
writeFileSync(join(arbre, 'electron', 'preload.cjs'), "const { contextBridge } = require('electron');\n");
assert.deepEqual(refusDElectron(arbre), [
{ fichier: join('electron', 'main.js'), specificateur: 'vitest', raison: "lanceur d'épreuves" },
{ fichier: join('electron', 'main.js'), specificateur: '../test/aide.js', raison: "hors d'electron/" },
{ fichier: join('electron', 'main.js'), argument: 'chemin', raison: 'argument calculé, chemin : il ne se vérifie pas' },
{ fichier: join('electron', 'main.js'), specificateur: 'svelte', raison: "hors du paquet, qui n'emporte aucun node_modules" },
]);
writeFileSync(join(arbre, 'electron', 'main.js'), '// Aucun chargement.\n');
writeFileSync(join(arbre, 'electron', 'preload.cjs'), '');
assert.throws(() => refusDElectron(arbre), /aucun chargement relevé dans electron\//);
} finally {
rmSync(arbre, { recursive: true, force: true });
}
});
test("package.json ne déclare aucune dépendance d'exécution : le paquet n'emporte aucun node_modules", () => {
const paquet = lireJson(RACINE, 'package.json');
assert.equal(paquet.dependencies, undefined);
assert.equal(paquet.optionalDependencies, undefined);
assert.match(paquet.devDependencies?.electron ?? '', /^\^44(\.|$)/);
assert.match(paquet.devDependencies?.['electron-builder'] ?? '', /^\^26(\.|$)/);
assert.equal(paquet.main, 'electron/main.js');
});
});
describe('livrable : identité', () => {
test("l'identifiant est celui de Capacitor, le nom de produit celui de package.json", () => {
const configuration = charger(RACINE);
assert.equal(configuration.appId, lireJson(RACINE, 'capacitor.config.json').appId);
assert.equal(configuration.productName, lireJson(RACINE, 'package.json').productName);
// Une source muette rend undefined des deux côtés de l'égalité, et
// electron-builder retombe alors sur ses valeurs par défaut : chaque
// valeur a aussi sa forme.
assert.match(configuration.appId ?? '', /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/);
assert.ok(
typeof configuration.productName === 'string' && configuration.productName.trim() !== '',
`productName vide : ${configuration.productName}`,
);
});
test('le titulaire entre dans la ressource de version, champ LegalCopyright', () => {
assert.equal(charger(RACINE).copyright, '© 2026 TechnoLibre');
});
test("le nom de l'application est un seul : productName de package.json, appName de Capacitor, celui du titre de la fenêtre (§ 18.6)", () => {
// electron-builder nomme l'application d'après productName, Capacitor
// d'après appName, et la page pose le titre de la fenêtre par
// titreAvecVersion : le nom suivi de la version affichée, que l'épreuve
// retire pour lire le nom.
const titre = titreAvecVersion(VERSION.affichee);
const suite = ` — ${VERSION.affichee}`;
assert.ok(titre.endsWith(suite), `le titre « ${titre} » ne finit pas sur « ${suite} »`);
const nom = titre.slice(0, -suite.length);
assert.ok(nom.trim() !== '', `le titre « ${titre} » ne porte aucun nom`);
assert.deepEqual(
{
productName: lireJson(RACINE, 'package.json').productName,
appName: lireJson(RACINE, 'capacitor.config.json').appName,
},
{ productName: nom, appName: nom },
);
});
});

61
test/machine.js Normal file
View file

@ -0,0 +1,61 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Réglages des séries node selon le nombre de cœurs de la machine (§ 14.14).
//
// Le budget de relance, deux secondes, tient avec des processus complets à
// partir de quatre cœurs. En deçà, le lanceur ne porte la série que dans un
// seul processus, et la relance qui suit un module de base — que presque
// toutes les épreuves importent — dépasse ce budget : de l'ordre de trois
// secondes sur deux cœurs. Une petite machine prend donc des threads, moins
// coûteux à démarrer qu'un processus, sur tous ses cœurs, et les épreuves
// lourdes de la série surveillée passent dans la série longue : la même
// relance y tient alors sous une seconde et demie.
//
// Aucune épreuve ne disparaît : elle change de série, et ce qu'elle éprouve
// ne dépend pas de la machine. Les threads partagent un processus : la garde
// de test/fils.test.js tient la série node à l'écart de l'état global du
// processus, sans quoi une épreuve en troublerait une autre.
import { availableParallelism } from 'node:os';
// Nombre de cœurs à partir duquel la série surveillée garde des processus
// complets et toutes ses épreuves.
export const SEUIL_COEURS = 4;
// Épreuves de la série surveillée qui passent dans node-long sur une petite
// machine. L'épreuve de bout en bout lance de vraies recherches : c'est la
// plus lourde de la série, et toute modification du moteur la rejoue.
export const LOURDES = Object.freeze(['src/moteur/integration.test.js']);
/**
* Réglages des projets node et node-long pour un nombre de cœurs.
*
* @param {number} coeurs entier ≥ 1
* @returns {{petiteMachine: boolean, coeurs: number, pool: 'forks'|'threads',
* maxWorkers: number|undefined, horsSerieSurveillee: string[]}}
* maxWorkers undefined laisse au lanceur son défaut
*/
export function reglagesSeries(coeurs) {
if (!Number.isInteger(coeurs) || coeurs < 1) {
throw new RangeError(`nombre de cœurs ${String(coeurs)} : entier ≥ 1 attendu`);
}
if (coeurs >= SEUIL_COEURS) {
return { petiteMachine: false, coeurs, pool: 'forks', maxWorkers: undefined, horsSerieSurveillee: [] };
}
return { petiteMachine: true, coeurs, pool: 'threads', maxWorkers: coeurs, horsSerieSurveillee: [...LOURDES] };
}
// Cœurs que ce processus peut occuper. availableParallelism suit l'affinité
// du processus : sous « taskset -c 0,1 », il rend 2 sur une machine qui en a
// seize, ce qui permet d'éprouver le mode d'une petite machine sur une grande.
export const coeursDisponibles = () => availableParallelism();
// Ligne que le rapporteur imprime, pour qu'un développeur sache sur quelle
// série sa relance a porté.
export function ligneMachine({ petiteMachine, coeurs, horsSerieSurveillee }) {
const compte = coeurs === 1 ? '1 cœur' : `${coeurs} cœurs`;
if (!petiteMachine) return `Machine : ${compte} — processus complets, série surveillée entière.`;
const ou = coeurs === 1 ? 'sur son seul cœur' : `sur les ${coeurs} cœurs`;
return `Machine : ${compte}, en deçà de ${SEUIL_COEURS} — threads ${ou} ; `
+ `en série longue : ${horsSerieSurveillee.join(', ')}.`;
}

104
test/machine.test.js Normal file
View file

@ -0,0 +1,104 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les réglages des séries node selon le nombre de cœurs (§ 14.14), et leur
// application dans vitest.config.js.
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { availableParallelism } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import configuration from '../vitest.config.js';
import { describe, test } from './lanceur.js';
import { LOURDES, SEUIL_COEURS, coeursDisponibles, ligneMachine, reglagesSeries } from './machine.js';
const RACINE = fileURLToPath(new URL('..', import.meta.url));
const projet = (nom) => configuration.test.projects.find(({ test: { name } }) => name === nom).test;
describe('machine : les réglages selon le nombre de cœurs (§ 14.14)', () => {
test('à partir de quatre cœurs, des processus complets et la série surveillée entière', () => {
for (const coeurs of [SEUIL_COEURS, 8, 16, 64]) {
assert.deepEqual(reglagesSeries(coeurs), {
petiteMachine: false,
coeurs,
pool: 'forks',
maxWorkers: undefined,
horsSerieSurveillee: [],
}, `${coeurs} cœurs`);
}
});
test('en deçà de quatre cœurs, des threads sur tous les cœurs, et les épreuves lourdes en série longue', () => {
for (const coeurs of [1, 2, 3]) {
assert.deepEqual(reglagesSeries(coeurs), {
petiteMachine: true,
coeurs,
pool: 'threads',
maxWorkers: coeurs,
horsSerieSurveillee: [...LOURDES],
}, `${coeurs} cœurs`);
}
});
test('un nombre de cœurs qui n’est pas un entier ≥ 1 lève RangeError', () => {
for (const coeurs of [0, -1, 2.5, Number.NaN, '4', undefined]) {
assert.throws(() => reglagesSeries(coeurs), RangeError, String(coeurs));
}
});
test('les épreuves lourdes existent, sont de la série surveillée, et la liste n’est pas vide', () => {
assert.ok(LOURDES.length > 0, 'aucune épreuve lourde nommée');
for (const fichier of LOURDES) {
assert.ok(existsSync(join(RACINE, fichier)), `${fichier} absent`);
assert.match(fichier, /\.test\.js$/);
assert.doesNotMatch(fichier, /\.(long|navigateur)\.test\.js$/, `${fichier} n'est pas de la série surveillée`);
}
});
test('les cœurs disponibles suivent l’affinité du processus, comme availableParallelism', () => {
assert.equal(coeursDisponibles(), availableParallelism());
assert.ok(coeursDisponibles() >= 1);
});
test('la ligne annoncée dit le mode, sur une petite machine comme sur une grande', () => {
assert.equal(
ligneMachine(reglagesSeries(2)),
'Machine : 2 cœurs, en deçà de 4 — threads sur les 2 cœurs ; '
+ 'en série longue : src/moteur/integration.test.js.',
);
assert.equal(
ligneMachine(reglagesSeries(1)),
'Machine : 1 cœur, en deçà de 4 — threads sur son seul cœur ; '
+ 'en série longue : src/moteur/integration.test.js.',
);
assert.equal(ligneMachine(reglagesSeries(16)), 'Machine : 16 cœurs — processus complets, série surveillée entière.');
});
});
describe('machine : vitest.config.js applique les réglages de cette machine', () => {
const reglages = reglagesSeries(coeursDisponibles());
test('les projets node et node-long prennent le pool et le nombre de processus retenus', () => {
for (const nom of ['node', 'node-long']) {
assert.equal(projet(nom).pool, reglages.pool, nom);
assert.equal(projet(nom).maxWorkers, reglages.maxWorkers, nom);
}
});
test('une épreuve hors de la série surveillée entre dans la série longue, et nulle part ailleurs', () => {
for (const fichier of reglages.horsSerieSurveillee) {
assert.ok(projet('node').exclude.includes(fichier), `${fichier} reste dans node`);
assert.ok(projet('node-long').include.includes(fichier), `${fichier} manque à node-long`);
}
for (const fichier of LOURDES.filter((f) => !reglages.horsSerieSurveillee.includes(f))) {
assert.ok(!projet('node').exclude.includes(fichier), `${fichier} sort de node sur cette machine`);
assert.ok(!projet('node-long').include.includes(fichier), `${fichier} entre dans node-long sur cette machine`);
}
});
test('le projet navigateur ne prend aucun de ces réglages', () => {
const navigateur = projet('navigateur');
assert.equal(navigateur.pool, undefined);
assert.equal(navigateur.maxWorkers, undefined);
});
});

33
test/projets.test.js Normal file
View file

@ -0,0 +1,33 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le projet node n'expose aucun DOM et ne compile aucun composant : un module
// qui toucherait document ou window, ou qui importerait un composant Svelte,
// y échoue au lieu de passer sur un environnement que la série node ne doit
// pas fournir (§ 14.4, § 14.8).
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { describe, test } from './lanceur.js';
describe('projet node', () => {
test('globalThis.document vaut undefined', () => {
assert.equal(globalThis.document, undefined);
});
test('globalThis.window vaut undefined', () => {
assert.equal(globalThis.window, undefined);
});
test('un composant Svelte ne se charge pas', async () => {
// Le composant existe, et son import échoue sur la lecture du source :
// sans greffon Svelte, l'analyse des imports de Vite rejette la syntaxe
// d'un .svelte, et sous « node --test » Node en refuse l'extension. Un
// refus venu d'ailleurs, comme un module qui lirait window au chargement,
// fait échouer l'épreuve au lieu de la satisfaire.
assert.ok(existsSync(new URL('../src/interface/App.svelte', import.meta.url)));
await assert.rejects(
import('../src/interface/App.svelte'),
/Failed to parse source for import analysis|Unknown file extension/,
);
});
});

193
test/rapporteur.js Normal file
View file

@ -0,0 +1,193 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Rapporteur des durées de Vitest, déclaré dans vitest.config.js à côté du
// rapporteur par défaut (§ 14.14). À la fin de chaque exécution, en série
// simple comme à chaque relance sous surveillance, il imprime la durée
// totale, la durée au mur de chaque projet et les dix épreuves les plus
// lentes. Un projet qui dépasse son plafond fait sortir le lanceur en code 1,
// et l'erreur le nomme.
//
// La durée d'un projet est celle de l'exécution, au mur, moins le temps où
// seuls d'autres projets ont un fichier en cours. Un fichier est en cours du
// moment où un processus de Vitest le prend (onTestModuleQueued) à celui où
// sa dernière épreuve finit (onTestModuleEnd) ; le démarrage du processus
// qui le prend, avant, n'est en cours pour aucun projet, et compte donc à
// chacun. Un projet qui tourne seul dure ainsi toute l'exécution, comme la
// durée qu'affiche Vitest ; avec d'autres, le temps où il attend un
// processus occupé ailleurs ne lui est pas compté, si bien que son plafond
// ne dépend pas des projets lancés avec lui. Un fichier que le rapporteur
// n'a pas vu prendre rend la durée de son projet inconnue, et un projet à
// plafond refuse alors au lieu de passer sans mesure (§ 14.2). Une exécution
// interrompue imprime son bilan sans rien refuser : ses durées sont
// partielles. Le bilan finit sur le mode que la machine a fait retenir
// (test/machine.js) : un développeur sait ainsi sur quelle série sa relance a
// porté.
import { coeursDisponibles, ligneMachine, reglagesSeries } from './machine.js';
// Plafond au mur de chaque projet, en millisecondes, par nom d'exécution du
// projet : celui de vitest.config.js, que Vitest fait suivre du navigateur
// entre parenthèses pour un projet navigateur. Le refus est généreux
// (§ 14.14) : un plafond calé sur la mesure échouerait sur une machine
// chargée, et un test capricieux se désactive.
export const PLAFONDS = new Map([
// La série node surveillée : trente secondes (§ 14.14). Seule, elle dure
// 1,8 s au mur sur 16 cœurs et 3,7 s sur 2, où elle se joue en threads
// (test/machine.js).
['node', 30_000],
// Posé sur la mesure de node-long : seule, 17 s au mur sur 16 cœurs et
// 28 s sur un seul. Le plafond laisse un facteur 2,1 au cas le plus lent.
['node-long', 60_000],
]);
// Nombre d'épreuves que nomme le bilan, des plus lentes.
const PLUS_LENTES = 10;
// Durée en secondes, deux décimales, virgule décimale.
const secondes = (ms) => `${(ms / 1000).toFixed(2).replace('.', ',')} s`;
// Longueur, en millisecondes, de la réunion d'intervalles { debut, fin }
// donnés dans n'importe quel ordre.
export function dureeReunie(intervalles) {
let total = 0;
let finReunie = -Infinity;
for (const { debut, fin } of [...intervalles].sort((a, b) => a.debut - b.debut)) {
if (fin > finReunie) {
total += fin - Math.max(debut, finReunie);
finReunie = fin;
}
}
return total;
}
// Les n épreuves les plus lentes parmi des { duree, projet, fichier, nom },
// de la plus lente à la plus rapide ; à durée égale, par fichier puis par
// nom, comparés par points de code.
export function plusLentes(epreuves, n) {
const avant = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
return [...epreuves]
.sort((a, b) => b.duree - a.duree || avant(a.fichier, b.fichier) || avant(a.nom, b.nom))
.slice(0, n);
}
// Bilan d'une exécution : ses lignes et ses refus. duree est celle de
// l'exécution ; projets associe à chaque nom de projet { duree, nonSuivis },
// nonSuivis listant les fichiers dont la durée manque ; epreuves porte chaque
// épreuve exécutée, { duree, projet, fichier, nom }. Les projets s'impriment
// triés par nom. Un projet refuse quand sa durée dépasse son plafond, ou
// quand il a un plafond et un fichier non suivi.
export function bilan({ duree, projets, epreuves, plafonds = PLAFONDS }) {
const lignes = [`Durée totale : ${secondes(duree)}`];
const refus = [];
for (const nom of [...projets.keys()].sort()) {
const { duree: dureeProjet, nonSuivis } = projets.get(nom);
const plafond = plafonds.get(nom);
const mesure =
nonSuivis.length === 0
? `${secondes(dureeProjet)} au mur`
: `durée inconnue, ${nonSuivis.length} fichier${nonSuivis.length > 1 ? 's' : ''} non suivi${nonSuivis.length > 1 ? 's' : ''}`;
lignes.push(` ${nom} : ${mesure}${plafond === undefined ? '' : `, plafond ${secondes(plafond)}`}`);
if (plafond === undefined) continue;
if (nonSuivis.length > 0) {
refus.push(
`Le projet ${nom} a un plafond de ${secondes(plafond)}, et sa durée ne se mesure pas : ` +
`le rapporteur n'a pas suivi ${nonSuivis.join(', ')} (§ 14.14).`,
);
} else if (dureeProjet > plafond) {
refus.push(
`Le projet ${nom} dure ${secondes(dureeProjet)} au mur, au-delà de son plafond de ${secondes(plafond)} (§ 14.14).`,
);
}
}
if (epreuves.length === 0) {
lignes.push('Aucune épreuve exécutée.');
} else {
const lentes = plusLentes(epreuves, PLUS_LENTES);
lignes.push(`Épreuves les plus lentes (${lentes.length} sur ${epreuves.length}) :`);
for (const { duree: dureeEpreuve, projet, fichier, nom } of lentes) {
lignes.push(`${String(Math.round(dureeEpreuve)).padStart(8)} ms ${projet} ${fichier} > ${nom}`);
}
}
return { lignes, refus };
}
// Le rapporteur que Vitest construit, sans option ; une épreuve lui passe sa
// propre horloge, en millisecondes, et ses propres plafonds.
export default class Rapporteur {
#horloge;
#plafonds;
#journal;
#debut;
// Intervalle { debut, fin } de chaque fichier pris pendant l'exécution
// courante, par identifiant de fichier ; fin reste undefined jusqu'à la
// fin du fichier.
#intervalles = new Map();
constructor({ horloge = () => performance.now(), plafonds = PLAFONDS } = {}) {
this.#horloge = horloge;
this.#plafonds = plafonds;
}
onInit(vitest) {
this.#journal = vitest.logger;
}
onTestRunStart() {
this.#debut = this.#horloge();
this.#intervalles = new Map();
}
onTestModuleQueued(module) {
this.#intervalles.set(module.id, { debut: this.#horloge(), fin: undefined });
}
onTestModuleEnd(module) {
const intervalle = this.#intervalles.get(module.id);
if (intervalle !== undefined) intervalle.fin = this.#horloge();
}
// Un fichier pris et jamais fini est en cours jusqu'à la fin de
// l'exécution. Le temps où seuls d'autres projets ont un fichier en cours
// vaut la réunion de tous les fichiers moins celle du projet : la durée
// du projet est donc celle de l'exécution, moins la première, plus la
// seconde.
onTestRunEnd(modules, _erreurs, raison) {
const fin = this.#horloge();
const duree = fin - this.#debut;
const parProjet = new Map();
const tous = [];
const epreuves = [];
for (const module of modules) {
const projet = module.project.name;
if (!parProjet.has(projet)) parProjet.set(projet, { intervalles: [], nonSuivis: [] });
const suivi = parProjet.get(projet);
const intervalle = this.#intervalles.get(module.id);
if (intervalle === undefined) {
suivi.nonSuivis.push(module.relativeModuleId);
} else {
const enCours = { debut: intervalle.debut, fin: intervalle.fin ?? fin };
suivi.intervalles.push(enCours);
tous.push(enCours);
}
for (const epreuve of module.children.allTests()) {
const diagnostic = epreuve.diagnostic();
if (diagnostic !== undefined) {
epreuves.push({ duree: diagnostic.duration, projet, fichier: module.relativeModuleId, nom: epreuve.fullName });
}
}
}
const horsDeTout = duree - dureeReunie(tous);
const projets = new Map(
[...parProjet].map(([projet, { intervalles, nonSuivis }]) => [
projet,
{ duree: horsDeTout + dureeReunie(intervalles), nonSuivis },
]),
);
const { lignes, refus } = bilan({ duree, projets, epreuves, plafonds: this.#plafonds });
for (const ligne of lignes) this.#journal.log(ligne);
this.#journal.log(ligneMachine(reglagesSeries(coeursDisponibles())));
if (raison === 'interrupted') return;
for (const message of refus) this.#journal.error(message);
if (refus.length > 0) process.exitCode = 1;
}
}

252
test/rapporteur.test.js Normal file
View file

@ -0,0 +1,252 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le rapporteur des durées (test/rapporteur.js, § 14.14) : sa mesure au mur,
// son bilan, son refus, et sa déclaration dans vitest.config.js. Le
// rapporteur s'éprouve ici hors de Vitest : une fausse horloge date chaque
// événement, et de faux fichiers portent les épreuves et leurs durées.
import assert from 'node:assert/strict';
import configuration from '../vitest.config.js';
import Rapporteur, { PLAFONDS, bilan, dureeReunie, plusLentes } from './rapporteur.js';
import { describe, test } from './lanceur.js';
import { coeursDisponibles, ligneMachine, reglagesSeries } from './machine.js';
// Un faux fichier d'épreuves, tel que Vitest le passe au rapporteur : son
// identifiant, son projet, son chemin, et ses épreuves, chacune
// { nom, duree } ; une durée absente fait une épreuve sautée, sans
// diagnostic.
const fichier = (projet, chemin, epreuves = []) => ({
id: `${projet}:${chemin}`,
project: { name: projet },
relativeModuleId: chemin,
children: {
*allTests() {
for (const { nom, duree } of epreuves) {
yield { fullName: nom, diagnostic: () => (duree === undefined ? undefined : { duration: duree }) };
}
},
},
});
// Une épreuve telle que la lisent plusLentes et bilan.
const epreuve = (duree, fichierDEpreuve, nom, projet = 'node') => ({ duree, projet, fichier: fichierDEpreuve, nom });
// Projets mesurés tels que les lit bilan, depuis [nom, durée] : chacun suivi
// en entier.
const mesures = (paires) => new Map(paires.map(([nom, duree]) => [nom, { duree, nonSuivis: [] }]));
// Un rapporteur branché sur une horloge réglée à la main, sur des plafonds
// d'épreuve et sur un journal qui garde ses lignes. Rend le rapporteur, de
// quoi régler l'horloge, et les deux listes du journal.
function brancher() {
let maintenant = 0;
const journal = { lignes: [], erreurs: [] };
const plafonds = new Map([['node', 30_000], ['node-long', 60_000]]);
const rapporteur = new Rapporteur({ horloge: () => maintenant, plafonds });
rapporteur.onInit({
logger: { log: (texte) => journal.lignes.push(texte), error: (texte) => journal.erreurs.push(texte) },
});
return { rapporteur, journal, regler: (t) => (maintenant = t) };
}
// Exécute fin(), puis rend le code de sortie qu'elle a posé et remet celui
// d'avant : l'épreuve ne laisse pas son code au processus qui l'exécute.
function codeDeSortieDe(fin) {
const avant = process.exitCode;
process.exitCode = undefined;
try {
fin();
return process.exitCode;
} finally {
process.exitCode = avant;
}
}
describe('rapporteur : mesure', () => {
test("dureeReunie mesure la réunion des intervalles, quel que soit leur ordre : chevauchés, emboîtés, bout à bout ou disjoints", () => {
assert.equal(dureeReunie([]), 0);
assert.equal(dureeReunie([{ debut: 5, fin: 9 }]), 4);
assert.equal(dureeReunie([{ debut: 20, fin: 25 }, { debut: 0, fin: 10 }]), 15);
assert.equal(dureeReunie([{ debut: 5, fin: 20 }, { debut: 0, fin: 10 }]), 20);
assert.equal(dureeReunie([{ debut: 0, fin: 30 }, { debut: 5, fin: 10 }, { debut: 12, fin: 31 }]), 31);
assert.equal(dureeReunie([{ debut: 10, fin: 20 }, { debut: 0, fin: 10 }]), 20);
});
test('plusLentes rend les n plus longues, de la plus lente à la plus rapide, à durée égale par fichier puis par nom', () => {
const epreuves = [
epreuve(3, 'b.test.js', 'x'),
epreuve(9, 'a.test.js', 'y'),
epreuve(3, 'a.test.js', 'z'),
epreuve(3, 'a.test.js', 'w'),
epreuve(1, 'a.test.js', 'v'),
];
assert.deepEqual(plusLentes(epreuves, 4), [
epreuve(9, 'a.test.js', 'y'),
epreuve(3, 'a.test.js', 'w'),
epreuve(3, 'a.test.js', 'z'),
epreuve(3, 'b.test.js', 'x'),
]);
assert.deepEqual(plusLentes(epreuves.slice(0, 2), 10), [epreuve(9, 'a.test.js', 'y'), epreuve(3, 'b.test.js', 'x')]);
});
});
describe('rapporteur : bilan et refus', () => {
test("un projet refuse au-delà de son plafond, pas à son plafond ; un projet sans plafond ne refuse jamais", () => {
const plafonds = new Map([['node', 30_000], ['node-long', 60_000]]);
const { refus } = bilan({
duree: 70_000,
projets: mesures([['node', 30_001], ['node-long', 60_000], ['navigateur', 90_000]]),
epreuves: [],
plafonds,
});
assert.deepEqual(refus, ['Le projet node dure 30,00 s au mur, au-delà de son plafond de 30,00 s (§ 14.14).']);
assert.deepEqual(bilan({ duree: 1, projets: mesures([['node', 30_000]]), epreuves: [], plafonds }).refus, []);
});
test("une exécution sans épreuve le dit au lieu d'une liste vide", () => {
assert.deepEqual(bilan({ duree: 4, projets: new Map(), epreuves: [], plafonds: new Map() }).lignes, [
'Durée totale : 0,00 s',
'Aucune épreuve exécutée.',
]);
});
test('le bilan donne la durée totale, chaque projet trié par nom avec son plafond, et les dix épreuves les plus lentes', () => {
const epreuves = Array.from({ length: 12 }, (_, i) => epreuve(10 * (i + 1), 'a.test.js', `e${i + 1}`));
const { lignes } = bilan({
duree: 2_345,
projets: mesures([['node-long', 2_000], ['navigateur', 1_234]]),
epreuves,
plafonds: new Map([['node-long', 60_000]]),
});
assert.deepEqual(lignes, [
'Durée totale : 2,35 s',
' navigateur : 1,23 s au mur',
' node-long : 2,00 s au mur, plafond 60,00 s',
'Épreuves les plus lentes (10 sur 12) :',
' 120 ms node a.test.js > e12',
' 110 ms node a.test.js > e11',
' 100 ms node a.test.js > e10',
' 90 ms node a.test.js > e9',
' 80 ms node a.test.js > e8',
' 70 ms node a.test.js > e7',
' 60 ms node a.test.js > e6',
' 50 ms node a.test.js > e5',
' 40 ms node a.test.js > e4',
' 30 ms node a.test.js > e3',
]);
});
});
describe('rapporteur : branché sur les événements de Vitest', () => {
test("chaque exécution compte à chaque projet sa durée moins le temps où seuls d'autres projets tournent, imprime son bilan, et sort en code 1 quand un projet dépasse son plafond", () => {
const { rapporteur, journal, regler } = brancher();
const a = fichier('node', 'src/a.test.js', [{ nom: 'a > un', duree: 7 }, { nom: 'a > sautée' }]);
const b = fichier('node', 'src/b.test.js', [{ nom: 'b > deux', duree: 29_000 }]);
const c = fichier('node-long', 'src/c.long.test.js', [{ nom: 'c > trois', duree: 19_000 }]);
regler(0);
rapporteur.onTestRunStart([]);
regler(100);
rapporteur.onTestModuleQueued(a);
rapporteur.onTestModuleQueued(c);
regler(400);
rapporteur.onTestModuleEnd(a);
regler(20_100);
rapporteur.onTestModuleEnd(c);
rapporteur.onTestModuleQueued(b);
regler(50_000);
rapporteur.onTestModuleEnd(b);
regler(50_500);
const code = codeDeSortieDe(() => rapporteur.onTestRunEnd([a, b, c], [], 'passed'));
assert.equal(code, 1);
// Aucun fichier ne tourne pendant 0,6 s, compté à chaque projet. node y
// ajoute ses fichiers, [100, 400] et [20 100, 50 000], soit 30,8 s : les
// 19,7 s où c tourne seul ne lui sont pas comptées. node-long y ajoute
// [100, 20 100], soit 20,6 s.
assert.deepEqual(journal.lignes.slice(0, 3), [
'Durée totale : 50,50 s',
' node : 30,80 s au mur, plafond 30,00 s',
' node-long : 20,60 s au mur, plafond 60,00 s',
]);
assert.deepEqual(journal.lignes.slice(3), [
'Épreuves les plus lentes (3 sur 3) :',
' 29000 ms node src/b.test.js > b > deux',
' 19000 ms node-long src/c.long.test.js > c > trois',
' 7 ms node src/a.test.js > a > un',
// Le bilan finit sur le mode que cette machine fait retenir.
ligneMachine(reglagesSeries(coeursDisponibles())),
]);
assert.deepEqual(journal.erreurs, ['Le projet node dure 30,80 s au mur, au-delà de son plafond de 30,00 s (§ 14.14).']);
// Une seconde exécution, comme une relance sous surveillance, repart de
// zéro. Seul, le projet dure toute l'exécution, temps d'avant la prise de
// son fichier compris ; sous son plafond, il ne pose aucun code de
// sortie.
journal.lignes.length = 0;
journal.erreurs.length = 0;
regler(60_000);
rapporteur.onTestRunStart([]);
regler(60_200);
rapporteur.onTestModuleQueued(a);
regler(60_300);
rapporteur.onTestModuleEnd(a);
regler(60_400);
assert.equal(codeDeSortieDe(() => rapporteur.onTestRunEnd([a], [], 'passed')), undefined);
assert.deepEqual(journal.lignes.slice(0, 2), ['Durée totale : 0,40 s', ' node : 0,40 s au mur, plafond 30,00 s']);
assert.deepEqual(journal.erreurs, []);
});
test("un fichier dont le rapporteur n'a vu ni la prise ni la fin rend la durée de son projet inconnue, et refuse quand le projet a un plafond", () => {
const { rapporteur, journal, regler } = brancher();
const a = fichier('node', 'src/a.test.js', [{ nom: 'a > un', duree: 1 }]);
const b = fichier('node', 'src/b.test.js', [{ nom: 'b > deux', duree: 1 }]);
const n = fichier('navigateur', 'src/n.navigateur.test.js', [{ nom: 'n > trois', duree: 1 }]);
regler(0);
rapporteur.onTestRunStart([]);
rapporteur.onTestModuleQueued(a);
regler(10);
rapporteur.onTestModuleEnd(a);
const code = codeDeSortieDe(() => rapporteur.onTestRunEnd([a, b, n], [], 'passed'));
assert.equal(code, 1);
assert.deepEqual(journal.lignes.slice(0, 3), [
'Durée totale : 0,01 s',
' navigateur : durée inconnue, 1 fichier non suivi',
' node : durée inconnue, 1 fichier non suivi, plafond 30,00 s',
]);
assert.deepEqual(journal.erreurs, [
"Le projet node a un plafond de 30,00 s, et sa durée ne se mesure pas : le rapporteur n'a pas suivi src/b.test.js (§ 14.14).",
]);
});
test("une exécution interrompue imprime son bilan sans rien refuser", () => {
const { rapporteur, journal, regler } = brancher();
const a = fichier('node', 'src/a.test.js', [{ nom: 'a > un', duree: 1 }]);
regler(0);
rapporteur.onTestRunStart([]);
rapporteur.onTestModuleQueued(a);
regler(40_000);
assert.equal(codeDeSortieDe(() => rapporteur.onTestRunEnd([a], [], 'interrupted')), undefined);
assert.deepEqual(journal.lignes.slice(0, 2), ['Durée totale : 40,00 s', ' node : 40,00 s au mur, plafond 30,00 s']);
assert.deepEqual(journal.erreurs, []);
});
});
describe('rapporteur : déclaration', () => {
test('vitest.config.js déclare le rapporteur à côté du rapporteur par défaut', () => {
assert.deepEqual(configuration.test.reporters, ['default', './test/rapporteur.js']);
});
test("chaque plafond nomme un projet de vitest.config.js sous son nom d'exécution, et node comme node-long ont le leur (§ 14.14)", () => {
// Vitest nomme chaque instance d'un projet navigateur d'après le projet
// et le navigateur, « navigateur (chromium) » : seul ce nom s'apparie à
// un plafond.
const projets = configuration.test.projects.flatMap(({ test: { name, browser } }) =>
browser?.enabled ? browser.instances.map((instance) => `${name} (${instance.browser})`) : [name],
);
assert.ok(projets.includes('navigateur (chromium)'), `noms d'exécution : ${projets.join(', ')}`);
assert.deepEqual([...PLAFONDS.keys()].filter((projet) => !projets.includes(projet)), []);
assert.ok(PLAFONDS.has('node'), 'aucun plafond pour node');
assert.ok(PLAFONDS.has('node-long'), 'aucun plafond pour node-long');
assert.equal(PLAFONDS.get('node'), 30_000);
assert.equal(PLAFONDS.get('node-long'), 60_000);
});
});

10
test/version_voisine.js Normal file
View file

@ -0,0 +1,10 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Version voisine d'une version affichée AAAA.MM.JJ.NN valide, pour les
// épreuves qui ont besoin d'une seconde version : même date, rang 01 changé
// en 02 et tout autre rang en 01. La date reste valide quelle qu'elle soit,
// un 29 février compris, et la voisine diffère toujours de la version reçue.
export function versionVoisine(version) {
return version.replace(/\d{2}$/, (rang) => (rang === '01' ? '02' : '01'));
}

1
version.json Normal file
View file

@ -0,0 +1 @@
{ "version": "2026.10.05.01" }

18
vite.config.js Normal file
View file

@ -0,0 +1,18 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Construit la page que servent la plateforme web de Capacitor et la coquille
// Electron. La base relative fait commencer chaque chemin d'actif par « ./ » :
// la coquille charge la page par file://, où un chemin absolu « /assets/… »
// désigne la racine du disque.
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
base: './',
plugins: [svelte()],
build: {
outDir: 'www',
emptyOutDir: true,
},
});

76
vitest.config.js Normal file
View file

@ -0,0 +1,76 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Un seul lanceur, trois projets (§ 14.4, § 14.8).
//
// node fonctions pures, sans DOM et sans greffon Svelte : un module qui
// importerait un composant échoue au chargement.
// node-long épreuves lourdes, hors de la série surveillée. Leur durée croît
// avec l'application : chacune dispose de deux minutes, là où le
// délai par défaut de Vitest, 5 s, la ferait échouer par
// dépassement et non sur un défaut. Ce délai arrête un test
// bloqué ; le plafond de la série est celui du rapporteur.
// navigateur un vrai moteur de rendu. Le projet étend vite.config.js : un
// module s'y résout par les mêmes greffons et réglages que dans la
// construction du livrable, sans seconde configuration à tenir
// d'accord avec la première.
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import { coeursDisponibles, reglagesSeries } from './test/machine.js';
// Les séries node suivent le nombre de cœurs de la machine (test/machine.js) :
// en deçà de quatre, des threads sur tous les cœurs, et les épreuves lourdes
// de la série surveillée passent dans la série longue (§ 14.14).
const SERIES = reglagesSeries(coeursDisponibles());
const PROCESSUS = { pool: SERIES.pool, maxWorkers: SERIES.maxWorkers };
export default defineConfig({
test: {
// test/rapporteur.js imprime, après le bilan par défaut, la durée de
// chaque projet et les dix épreuves les plus lentes, et fait échouer
// l'exécution quand node ou node-long dépasse son plafond (§ 14.14). Un
// chemin relatif se lit depuis la racine du projet.
reporters: ['default', './test/rapporteur.js'],
// Sous surveillance, une épreuve se relance quand un module de son graphe
// d'imports change. Les fichiers des motifs se lisent hors de tout
// graphe : le contrôle de version (§ 18.5) lit les premiers par le
// système de fichiers ; la coquille s'exécute dans un processus à part,
// la configuration d'electron-builder se charge par un require natif et
// lit capacitor.config.json, et le script de construction se lance
// depuis une copie. Chaque motif rattache ses fichiers aux épreuves qui
// les lisent, et au contrôle de version quand son point 6 les balaie. Un
// fichier qui répond à un motif ne relance que les épreuves nommées, et
// plus celles qui l'importeraient : un motif ne nomme donc aucun fichier
// qu'une épreuve importe. L'option se lit au niveau du lanceur : posée
// dans un 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' },
{ pattern: /(^|\/)electron\/[^/]+$/,
testsToRun: () => ['test/coquille.test.js', 'test/livrable.test.js', 'scripts/version.test.js'] },
{ pattern: /(^|\/)test\/electron_factice\.js$/,
testsToRun: () => ['test/coquille.test.js', 'scripts/version.test.js'] },
{ pattern: /(^|\/)(electron-builder\.config\.cjs|capacitor\.config\.json)$/,
testsToRun: () => ['test/livrable.test.js', 'scripts/construire_windows.test.js', 'scripts/version.test.js'] },
{ pattern: /(^|\/)scripts\/construire_windows\.sh$/,
testsToRun: () => ['scripts/construire_windows.test.js', 'scripts/version.test.js'] },
],
projects: [
{ test: {
name: 'node', environment: 'node', ...PROCESSUS,
include: ['src/**/*.test.js', 'scripts/**/*.test.js', 'test/**/*.test.js'],
exclude: ['**/*.long.test.js', '**/*.navigateur.test.js', 'node_modules/**',
...SERIES.horsSerieSurveillee] } },
{ test: {
name: 'node-long', environment: 'node', ...PROCESSUS,
include: ['src/**/*.long.test.js', 'test/**/*.long.test.js', ...SERIES.horsSerieSurveillee],
testTimeout: 120_000 } },
{ extends: './vite.config.js',
test: {
name: 'navigateur',
include: ['src/**/*.navigateur.test.js'],
browser: { enabled: true, headless: true, provider: playwright(),
instances: [{ browser: 'chromium' }] } } },
],
},
});