Compare commits

...

8 commits

Author SHA1 Message Date
82e57f47df [FIX] web storage: refuse a name Chromium did not convert, UTF-8 launch
Under a non-UTF-8 locale, Chromium returns the parent folder for an
accented folder name without raising, so a write meant for that folder
replaced a file of the same name in its parent. Each step of a path walk
now compares the handle with its parent and refuses NOM_NON_CONVERTI —
LECTURE on reads, ECRITURE on writes — and nothing lands elsewhere. The
test launcher gives Chromium LANG=C.UTF-8 when the locale is not UTF-8, as
the containers do, and the matrix installs ps where an image lacks it.
Checked: 1776 node and 45 browser tests; the guard holds under LC_ALL=C.

--- FR ---

[FIX] stockage web : refuser un nom non converti, lancement en UTF-8

Hors locale UTF-8, Chromium rend le dossier parent pour un nom de dossier
accentué, sans lever : une écriture destinée à ce dossier remplaçait un
fichier homonyme de son parent. Chaque pas d'un chemin compare désormais le
dossier obtenu à son parent et refuse NOM_NON_CONVERTI — LECTURE en lecture,
ECRITURE en écriture — : rien n'atterrit ailleurs. Le lanceur des épreuves
donne LANG=C.UTF-8 à Chromium quand la locale n'est pas UTF-8, comme les
conteneurs, et la matrice installe ps là où une image en manque.
Vérifié : 1776 node, 45 navigateur ; la garde tient sous LC_ALL=C.

Assisted-by: Claude Opus 5.5
2026-10-07 02:35:12 -04:00
71076afe4d [ADD] application: clock, translation, formatting, displayed name
The screens need one clock, one translation mechanism and one way to
format and name, or each component grows its own. horloge.js is the only
code that reads time and arms timers, injected so tests drive it.
traduction.js keys texts by domain, in two families of tables, raises on a
missing key and never shows a raw key; libelles/ splits the label table by
domain. format.js formats numbers and dates in French, and personnes.js
composes the displayed name the frozen labels use. Nothing persists
between sessions.
Checked: 1773 node and 43 browser tests.

--- FR ---

[ADD] application : horloge, traduction, mise en forme, nom affiché

Les écrans exigent une horloge, un mécanisme de traduction et une façon de
mettre en forme et de nommer, sans quoi chaque composant ferait les siens.
horloge.js est le seul code qui lit l'heure et arme des minuteries, injecté
pour que les épreuves le pilotent. traduction.js range les textes par
domaine, en deux familles de tables, lève sur une clé absente et ne montre
jamais une clé brute ; libelles/ découpe la table des libellés par domaine.
format.js met en forme nombres et dates en français, et personnes.js
compose le nom affiché qu'emploient les libellés figés. Rien ne persiste
d'une séance à l'autre.
Vérifié : 1773 node et 43 navigateur.

Assisted-by: Claude Opus 5.5
2026-10-07 02:33:39 -04:00
63b997faa9 [ADD] interface: colour tokens, two themes, checked palette, marks
The plan's colours carry meaning — a conflict, a reservation, a mode — so
they must stay readable for every reader and in both themes. jetons.css
defines the tokens for the light and dark themes, set by data-theme on the
root; palette.js reads them from the stylesheet text and checks, by
computation, contrast to WCAG and separation under the three
dichromacies, keeping the conflict hue reserved. The theme follows the
system at each start and the operator's choice lasts the session.
Checked: 1709 node and 43 browser tests.

--- FR ---

[ADD] interface : jetons de couleur, deux thèmes, palette, marques

Les couleurs du plan portent un sens — un conflit, une réservation, un mode
— et doivent rester lisibles pour tout lecteur et dans les deux thèmes.
jetons.css définit les jetons des thèmes clair et sombre, posés par
data-theme sur la racine ; palette.js les lit dans le texte de la feuille
et contrôle, par calcul, le contraste au sens des WCAG et la séparation
sous les trois dichromaties, la teinte du conflit restant réservée. Le
thème suit le système à chaque démarrage ; le choix de l'opérateur vaut
pour la séance.
Vérifié : 1709 node et 43 navigateur.

Assisted-by: Claude Opus 5.5
2026-10-07 02:33:30 -04:00
eec14bdcb0 [ADD] geometry: tables, their chairs, designating a seat
A table's tabletop, chairs, footprint and handle derive from its number of
places, chairs numbered clockwise from the top, in drawing centimetres.
The seat a pointer designates is read from the same chairs the drawing
places, through one implementation, so what is drawn and where a drop
lands cannot disagree. No table rotates.
Checked: 1681 node tests.

--- FR ---

[ADD] géométrie : les tables, leurs chaises, la désignation d'un siège

Le plateau, les chaises, l'emprise et la poignée d'une table dérivent de son
nombre de places, chaises numérotées depuis le haut dans le sens horaire,
en centimètres de dessin. Le siège que désigne un pointeur se lit sur les
chaises mêmes que le dessin pose, par une seule implémentation : ce qui est
dessiné et l'endroit où tombe un dépôt ne peuvent diverger. Aucune table ne
tourne.
Vérifié : 1681 épreuves node.

Assisted-by: Claude Opus 5.5
2026-10-07 02:32:47 -04:00
1ea572468d [ADD] geometry: plan view, truncation, scale bar, measured constants
The plan needs one formula between the screen and the drawing, shared by
the drawing, the seat designation and the drop of a drag. src/geometrie/vue
holds the view as k px/cm plus a translation, written in a single
transform attribute, and converts pointer positions without reading the
DOM. Text truncation, the graphic scale and the register of measured
constants live beside it; the layer guard now requires src/geometrie and
keeps it deterministic and away from window.
Checked: 1619 node tests.

--- FR ---

[ADD] géométrie : vue du plan, troncature, échelle, constantes mesurées

Le plan exige une seule formule entre l'écran et le dessin, que partagent le
dessin, la désignation d'un siège et le dépôt d'un glissement.
src/geometrie/vue tient la vue — k px/cm et une translation — écrite dans un
seul attribut transform, et convertit les positions du pointeur sans lire
le DOM. La troncature du texte, l'échelle graphique et le registre des
constantes mesurées vivent à côté ; la garde des couches exige désormais
src/geometrie, déterministe et sans window.
Vérifié : 1619 épreuves node.

Assisted-by: Claude Opus 5.5
2026-10-07 02:32:35 -04:00
7667134bd7 [ADD] engine: conflicts per table, aggregated diversity, diagnosis extras
The plan view must say at which table and round a conflict sits, and the
quality page must aggregate diversity. indicateurs.js now exports the single
pass that reads a plan round by round and table by table, from which
mesurer draws every figure; conflits.js gives its detail per table, so the
totals and the table view share one arithmetic (§ 13.2). The diagnosis
gains its repartition, remedies, single-affiliation flag and the anchors
that force a return.
Checked: 1582 node and 60 node-long tests; indicateurs.js at 100 %.

--- FR ---

[ADD] moteur : conflits par table, diversité agrégée, diagnostic enrichi

La vue du plan doit dire à quelle table et à quel tour se trouve un
conflit, et la page de qualité agréger la diversité. indicateurs.js exporte
le seul passage qui lit un plan tour par tour et table par table, d'où
mesurer tire chaque chiffre ; conflits.js en rend le détail par table : les
totaux et la vue par table partagent une arithmétique (§ 13.2). Le
diagnostic gagne sa répartition, ses remèdes, le drapeau d'appartenance
unique et les ancrages qui imposent un retour.
Vérifié : 1582 node et 60 node-long ; indicateurs.js à 100 %.

Assisted-by: Claude Opus 5.5
2026-10-07 02:32:12 -04:00
03c04bdf16 [ADD] storage: empty chair in a table list when seats are assigned
The operator plans, the room is not tracked live: removing a person from
the retained plan must free her chair and leave every other seat where it
is. With seats assigned, a table list may now hold null before its last
occupant, seat 1 included, and never end on it; without assignment no
list holds null. Validation, canonical form, patches and the named form
handle it, and the delivered demos keep their bytes.
Checked: 1549 node and 58 node-long tests; depot.js, journal.js at 100 %.

--- FR ---

[ADD] stockage : chaise vide dans une liste de table aux sièges attribués

L'opérateur planifie, la salle ne se suit pas en direct : retirer une
personne du plan retenu doit libérer sa chaise et laisser tout autre siège
à sa place. Sièges attribués, une liste de table peut porter null avant son
dernier occupant, siège 1 compris, jamais à la fin ; sans attribution,
aucune liste ne porte null. Contrôle, forme canonique, correctifs et forme
nommée le traitent, et les démonstrations livrées gardent leurs octets.
Vérifié : 1549 node et 58 node-long ; depot.js, journal.js à 100 %.

Assisted-by: Claude Opus 5.5
2026-10-07 02:31:26 -04:00
63efdb21a1 [FIX] matrix: UTF-8 locale for Chromium, skip ps reading when absent
On Linux, Chromium converts origin-private file names through the process
locale. The debian:13 and fedora:44 images set no LANG, so every accented
name failed with TypeMismatchError there, while Arch, which sets C.UTF-8,
passed. The container steps now run under LANG=C.UTF-8, and a browser test
names that cause when a Chromium cannot write an accented name. The long
test that reads the real ps skips with its reason when ps is missing.
Checked: debian-13 and fedora-44 test_navigateur and test_long green in
containers; 36 browser and 26 node-long tests locally.

--- FR ---

[FIX] matrice : locale UTF-8 pour Chromium, lecture ps sautée sans ps

Sous Linux, Chromium convertit les noms de fichiers de l'origine privée par
la locale du processus. Les images debian:13 et fedora:44 ne posent aucun
LANG : chaque nom accentué y échouait (TypeMismatchError), alors qu'Arch,
qui pose C.UTF-8, passait. Les étapes des conteneurs tournent sous
LANG=C.UTF-8, et une épreuve du navigateur nomme cette cause quand un
Chromium n'écrit pas un nom accentué. L'épreuve longue qui lit le vrai ps se
saute, raison dite, quand ps manque.
Vérifié : debian-13 et fedora-44 au vert en conteneur ; 36 + 26 en local.

Assisted-by: Claude Opus 5.5
2026-10-07 02:14:19 -04:00
80 changed files with 7406 additions and 989 deletions

View file

@ -17,6 +17,12 @@
# que celle de l'hôte, NixOS lui-même (l'image nix éprouve shell.nix, sur # que celle de l'hôte, NixOS lui-même (l'image nix éprouve shell.nix, sur
# une base qui n'est pas NixOS), une vraie session graphique. # une base qui n'est pas NixOS), une vraie session graphique.
# #
# Les étapes tournent sous LANG=C.UTF-8, des paramètres régionaux UTF-8 que
# la plupart des images ne posent pas, et sous lesquels Chromium nomme une
# entrée hors ASCII de l'OPFS. La préparation installe ps (procps) là où
# l'image ne le porte pas : une épreuve de la coquille lit par lui l'heure de
# démarrage d'un processus.
#
# GTT_PARALLELES (4 par défaut) borne les conteneurs simultanés ; les # GTT_PARALLELES (4 par défaut) borne les conteneurs simultanés ; les
# journaux vont dans GTT_JOURNAUX, un dossier temporaire par défaut. Les # journaux vont dans GTT_JOURNAUX, un dossier temporaire par défaut. Les
# téléchargements — npm, Electron, Chromium, Node — se partagent entre # téléchargements — npm, Electron, Chromium, Node — se partagent entre
@ -103,24 +109,32 @@ rm -rf "$source_copie"
mkdir -p "$source_copie" mkdir -p "$source_copie"
(cd "$racine" && git ls-files -co --exclude-standard -z | tar --null -cf - -T - | tar -xf - -C "$source_copie") (cd "$racine" && git ls-files -co --exclude-standard -z | tar --null -cf - -T - | tar -xf - -C "$source_copie")
# Préparation sous root, par famille : sudo et un compte ordinaire « dev » # Préparation sous root, par famille : sudo, ps quand l'image ne le porte
# qui l'emploie sans mot de passe, comme un poste de développement. Son uid # pas — procps-ng chez Fedora, AlmaLinux, Rocky et Arch, procps ailleurs —,
# est le même dans chaque conteneur — 1500, libre aussi sous Ubuntu, dont # et un compte ordinaire « dev » qui emploie sudo sans mot de passe, comme un
# l'image donne 1000 à son propre compte — : les caches partagés ne # poste de développement. Son uid est le même dans chaque conteneur — 1500,
# changent jamais de propriétaire. # libre aussi sous Ubuntu, dont l'image donne 1000 à son propre compte — :
# les caches partagés ne changent jamais de propriétaire.
preparation() { preparation() {
case "$1" in case "$1" in
ubuntu-* | debian-*) echo 'apt-get update -q && DEBIAN_FRONTEND=noninteractive apt-get install -y -q sudo' ;; ubuntu-* | debian-*) echo 'apt-get update -q && DEBIAN_FRONTEND=noninteractive apt-get install -y -q sudo $(command -v ps > /dev/null || echo procps)' ;;
fedora-* | almalinux-* | rocky-*) echo 'dnf install -y sudo shadow-utils' ;; fedora-* | almalinux-* | rocky-*) echo 'dnf install -y sudo shadow-utils $(command -v ps > /dev/null || echo procps-ng)' ;;
opensuse-*) echo 'zypper --non-interactive install sudo shadow' ;; opensuse-*) echo 'zypper --non-interactive install sudo shadow $(command -v ps > /dev/null || echo procps)' ;;
arch) echo 'pacman -Syu --noconfirm sudo' ;; arch) echo 'pacman -Syu --noconfirm sudo $(command -v ps > /dev/null || echo procps-ng)' ;;
esac esac
} }
# Étapes sous le compte dev, chacune annoncée par une ligne « === étape # Étapes sous le compte dev, chacune annoncée par une ligne « === étape
# statut » que le bilan relit. L'essai et le serveur de Vite ne se jouent # statut » que le bilan relit. L'essai et le serveur de Vite ne se jouent
# que sur une installation réussie. # que sur une installation réussie. Elles tournent sous LANG=C.UTF-8, des
# paramètres régionaux UTF-8 comme ceux d'un poste de développement, que la
# plupart des images ne posent pas : sous le « C » qu'elles laissent,
# Chromium ne convertit plus un nom hors ASCII de l'OPFS, et les épreuves
# du navigateur n'éprouvent plus le projet. C.UTF-8 vient avec la glibc,
# sans paquet de langue ; une image qui ne le porterait pas retomberait sur
# « C », comme sans la variable.
ETAPES_DEV=' ETAPES_DEV='
export LANG=C.UTF-8
etape() { local nom="$1"; shift; if "$@"; then echo "=== $nom ok"; else echo "=== $nom ÉCHEC"; return 1; fi; } etape() { local nom="$1"; shift; if "$@"; then echo "=== $nom ok"; else echo "=== $nom ÉCHEC"; return 1; fi; }
cd "$HOME/projet" cd "$HOME/projet"
etape install_dev ./install_dev.sh || exit 1 etape install_dev ./install_dev.sh || exit 1

View file

@ -1565,7 +1565,12 @@ régénérable.
Puis, **par tour** : la liste des tables, chacune étant la liste des 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 **identifiants entiers** de ses occupants **dans l'ordre des sièges** ; et la
**réserve**, la liste des identifiants assis nulle part à ce tour. Le numéro de **réserve**, la liste des identifiants assis nulle part à ce tour. Le numéro de
siège n'est pas stocké : il est la position dans la liste. siège n'est pas stocké : il est la position dans la liste. Quand les sièges sont
attribués, une chaise vide avant le dernier occupant, siège 1 compris, s'écrit
`null` à sa position, et une liste ne finit jamais par `null` : retirer une
personne libère sa chaise, et aucun autre siège ne change — le placement se
prépare, il ne suit pas la salle.
Sans attribution, aucune liste ne porte `null`.
**Quand les sièges ne sont pas attribués** (§ 5.3), l'ordre à l'intérieur d'une **Quand les sièges ne sont pas attribués** (§ 5.3), l'ordre à l'intérieur d'une
table ne porte aucune information : le logiciel l'écrit **trié par identifiant table ne porte aucune information : le logiciel l'écrit **trié par identifiant

View file

@ -30,28 +30,15 @@ import { serialiserCharge } from '../stockage/canonique.js';
import { SCHEMA, clesRangees, creerCharge, etatDeduit, premiereFaute } from '../stockage/document.js'; import { SCHEMA, clesRangees, creerCharge, etatDeduit, premiereFaute } from '../stockage/document.js';
import { ErreurStockage } from '../stockage/erreurs.js'; import { ErreurStockage } from '../stockage/erreurs.js';
import { fautes, formeNommee, versFichier } from '../stockage/placements.js'; import { fautes, formeNommee, versFichier } from '../stockage/placements.js';
import { COMMANDES_MOBILIER } from './commandes_mobilier.js';
import { COMMANDES_PLACES } from './commandes_places.js';
import { ErreurCommande, refus } from './erreurs.js';
import { libelle } from './libelles.js'; import { libelle } from './libelles.js';
import { nomAffiche } from './personnes.js';
/** // Le refus d'une commande vit dans erreurs.js, le nom affiché d'une personne
* Le refus d'une commande : son code, et des détails que l'appelant lit pour // dans personnes.js : les importateurs de ce module les y trouvent encore.
* agir, remede compris. Le message, code et détails en JSON, sert aux traces ; export { ErreurCommande, nomAffiche };
* le texte affiché vient de la table des libellés (§ 14.6).
*/
export class ErreurCommande extends Error {
/**
* @param {string} code
* @param {Object} [details]
*/
constructor(code, details = {}) {
super(`${code} ${JSON.stringify(details)}`);
this.code = code;
this.details = details;
}
}
ErreurCommande.prototype.name = 'ErreurCommande';
// Le refus de code donné : ses détails, puis le remède, null sans lui.
const refus = (code, details = {}, remede = null) => new ErreurCommande(code, { ...details, remede });
// Un avertissement : il n'empêche rien. // Un avertissement : il n'empêche rien.
const avertissement = (code, details) => ({ code, details }); const avertissement = (code, details) => ({ code, details });
@ -120,9 +107,6 @@ function participantDe(charge, id) {
return participant; return participant;
} }
// Le nom d'une personne tel que les libellés l'écrivent : prénom, puis nom.
const personneDe = ({ nom, prenom }) => (prenom === null ? nom : `${prenom} ${nom}`);
// Un plan bloqué refuse toute modification ; débloquer seul le lève (§ 9). // Un plan bloqué refuse toute modification ; débloquer seul le lève (§ 9).
function exigerModifiable(charge) { function exigerModifiable(charge) {
if (charge.evenement.etat === 'bloque') throw refus('PLAN_BLOQUE', {}, { geste: 'debloquer' }); if (charge.evenement.etat === 'bloque') throw refus('PLAN_BLOQUE', {}, { geste: 'debloquer' });
@ -171,7 +155,7 @@ function ajouterParticipant(charge, { champs }) {
suivante.prochainsIds.participant = id + 1; suivante.prochainsIds.participant = id + 1;
return { return {
charge: suivante, charge: suivante,
libelle: libelle('ENTREE_AJOUTER_PARTICIPANT', { personne: personneDe(participant) }), libelle: libelle('ENTREE_AJOUTER_PARTICIPANT', { personne: nomAffiche(participant) }),
avertissements: [], avertissements: [],
}; };
} }
@ -192,9 +176,9 @@ function modifierParticipant(charge, { id, champs }) {
return { return {
charge: avecParticipant(charge, id, () => apres), charge: avecParticipant(charge, id, () => apres),
libelle: libelle('ENTREE_MODIFIER_PARTICIPANT', { libelle: libelle('ENTREE_MODIFIER_PARTICIPANT', {
personne: personneDe(avant), personne: nomAffiche(avant),
champs: changes, champs: changes,
devenu: renomme ? personneDe(apres) : null, devenu: renomme ? nomAffiche(apres) : null,
}), }),
avertissements: [], avertissements: [],
}; };
@ -206,7 +190,7 @@ function modifierParticipant(charge, { id, champs }) {
function bilanExclusion(avant, apres, participant) { function bilanExclusion(avant, apres, participant) {
const reservations = avant.reservations.filter((reservation) => reservation.participant === participant.id).length; const reservations = avant.reservations.filter((reservation) => reservation.participant === participant.id).length;
const titres = Math.abs(titresPourvus(avant) - titresPourvus(apres)); const titres = Math.abs(titresPourvus(avant) - titresPourvus(apres));
return { participant: participant.id, personne: personneDe(participant), reservations, titres }; return { participant: participant.id, personne: nomAffiche(participant), reservations, titres };
} }
/** /**
@ -255,7 +239,7 @@ function supprimerParticipant(charge, { id }) {
const reservations = charge.reservations.length - suivante.reservations.length; const reservations = charge.reservations.length - suivante.reservations.length;
const details = { const details = {
participant: id, participant: id,
personne: personneDe(participant), personne: nomAffiche(participant),
reservations, reservations,
titres: titresPourvus(charge) - titresPourvus(suivante), titres: titresPourvus(charge) - titresPourvus(suivante),
}; };
@ -423,28 +407,52 @@ function effacerPropositions(charge) {
}; };
} }
/**
* Un registre réuni de plusieurs : chaque nom une fois, dans l'ordre des
* registres reçus, puis de chacun.
*
* @param {...Map<string, Function>} registres
* @returns {Map<string, Function>}
* @throws {RangeError} un nom porté par deux registres, qu'il nomme
*/
export function reunirCommandes(...registres) {
const reunies = new Map();
for (const registre of registres) {
for (const [nom, commande] of registre) {
if (reunies.has(nom)) throw new RangeError(`commandes : ${JSON.stringify(nom)} est dans deux registres`);
reunies.set(nom, commande);
}
}
return reunies;
}
/** /**
* Le registre des commandes, par nom : chacune (charge, arguments) → * Le registre des commandes, par nom : chacune (charge, arguments) →
* { charge, libelle, avertissements }, et importerParticipants y ajoute le * { charge, libelle, avertissements }, et importerParticipants y ajoute le
* résumé de l'import. * résumé de l'import. Il réunit les commandes de ce module, celles du
* mobilier et celles des places.
* *
* @typedef {{code: string, details: Object}} Avertissement * @typedef {{code: string, details: Object}} Avertissement
* @typedef {{charge: Object, libelle: string, avertissements: Avertissement[]}} Rendu * @typedef {{charge: Object, libelle: string, avertissements: Avertissement[]}} Rendu
* @type {Map<string, (charge: Object, args: Object) => Rendu>} * @type {Map<string, (charge: Object, args: Object) => Rendu>}
*/ */
export const COMMANDES = new Map([ export const COMMANDES = reunirCommandes(
['ajouterParticipant', ajouterParticipant], new Map([
['modifierParticipant', modifierParticipant], ['ajouterParticipant', ajouterParticipant],
['exclureParticipant', exclureParticipant], ['modifierParticipant', modifierParticipant],
['reintegrerParticipant', reintegrerParticipant], ['exclureParticipant', exclureParticipant],
['supprimerParticipant', supprimerParticipant], ['reintegrerParticipant', reintegrerParticipant],
['importerParticipants', importerParticipants], ['supprimerParticipant', supprimerParticipant],
['changerEtat', changerEtat], ['importerParticipants', importerParticipants],
['debloquer', debloquer], ['changerEtat', changerEtat],
['enregistrerGeneration', enregistrerGeneration], ['debloquer', debloquer],
['retenirProposition', retenirProposition], ['enregistrerGeneration', enregistrerGeneration],
['effacerPropositions', effacerPropositions], ['retenirProposition', retenirProposition],
]); ['effacerPropositions', effacerPropositions],
]),
COMMANDES_MOBILIER,
COMMANDES_PLACES,
);
/** /**
* Applique la commande nom à la charge : ce qu'elle rend, avertissements [] * Applique la commande nom à la charge : ce qu'elle rend, avertissements []

View file

@ -14,7 +14,12 @@ import { rechercher } from '../moteur/recherche.js';
import { configurationDepuisCharge, creerCharge, etatDeduit } from '../stockage/document.js'; import { configurationDepuisCharge, creerCharge, etatDeduit } from '../stockage/document.js';
import { derive, fautes, formeNommee, planDepuisFichier } from '../stockage/placements.js'; import { derive, fautes, formeNommee, planDepuisFichier } from '../stockage/placements.js';
import { VERSION } from '../version.genere.js'; import { VERSION } from '../version.genere.js';
import { COMMANDES, ErreurCommande, appliquerCommande, chargeNeuve, formeNommeeDe } from './commandes.js'; import { readFileSync } from 'node:fs';
import { chargements } from '../../test/chargements.js';
import { COMMANDES, ErreurCommande, appliquerCommande, chargeNeuve, formeNommeeDe, reunirCommandes } from './commandes.js';
import { COMMANDES_MOBILIER } from './commandes_mobilier.js';
import { COMMANDES_PLACES } from './commandes_places.js';
import { ErreurCommande as ErreurDesErreurs, refus } from './erreurs.js';
const V1 = VERSION.affichee; const V1 = VERSION.affichee;
@ -603,3 +608,61 @@ describe('commandes : la charge reçue ne change jamais (§ 13.2)', () => {
} }
}); });
}); });
describe('commandes : les registres réunis et les erreurs partagées', () => {
test("reunirCommandes réunit des registres ; un nom de deux registres lève et le nomme", () => {
const premiere = () => null;
const seconde = () => null;
const reunies = reunirCommandes(new Map([['essaiUn', premiere]]), new Map([['essaiDeux', seconde]]));
assert.ok(reunies instanceof Map);
assert.deepEqual([...reunies.keys()], ['essaiUn', 'essaiDeux']);
assert.equal(reunies.get('essaiDeux'), seconde);
assert.throws(
() => reunirCommandes(new Map([['essaiPartage', premiere]]), new Map([['essaiPartage', seconde]])),
(erreur) => {
assert.match(erreur.message, /essaiPartage/);
return true;
},
);
});
test("COMMANDES réunit les onze commandes d'A1 et les deux registres de F1, vides tant que leurs tâches ne les remplissent pas", () => {
assert.equal(COMMANDES.size, 11 + COMMANDES_MOBILIER.size + COMMANDES_PLACES.size);
assert.ok(COMMANDES_MOBILIER instanceof Map);
assert.ok(COMMANDES_PLACES instanceof Map);
for (const registre of [COMMANDES_MOBILIER, COMMANDES_PLACES]) {
for (const [nom, commande] of registre) assert.equal(COMMANDES.get(nom), commande, nom);
}
});
test("ErreurCommande importée de commandes.js est celle d'erreurs.js ; refus pose details.remede", () => {
assert.equal(ErreurCommande, ErreurDesErreurs);
const sans = refus('NOM_REQUIS');
assert.ok(sans instanceof ErreurCommande);
assert.equal(sans.name, 'ErreurCommande');
assert.equal(sans.code, 'NOM_REQUIS');
assert.deepEqual(sans.details, { remede: null });
const avec = refus('PLAN_BLOQUE', { table: 2 }, { geste: 'debloquer' });
assert.deepEqual(avec.details, { table: 2, remede: { geste: 'debloquer' } });
refuse(() => executer('debloquer', chargeEssai(), {}), 'PLAN_NON_BLOQUE');
});
test("ni commandes_mobilier.js ni commandes_places.js n'atteignent commandes.js par leurs imports, à toute profondeur", () => {
const cible = new URL('./commandes.js', import.meta.url).href;
for (const depart of ['./commandes_mobilier.js', './commandes_places.js']) {
const vus = new Set();
const aVoir = [new URL(depart, import.meta.url).href];
while (aVoir.length > 0) {
const adresse = aVoir.pop();
if (vus.has(adresse)) continue;
vus.add(adresse);
for (const { specificateur, argument } of chargements(readFileSync(new URL(adresse), 'utf8'))) {
assert.equal(argument, undefined, `${adresse} : chargement calculé`);
if (specificateur.startsWith('.')) aVoir.push(new URL(specificateur, adresse).href);
}
}
assert.ok(vus.size >= 1, `${depart} : aucun module parcouru`);
assert.ok(!vus.has(cible), `${depart} atteint commandes.js`);
}
});
});

View file

@ -0,0 +1,9 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le registre des commandes du mobilier — tables, sièges, tours (§ 6, § 8.2),
// que commandes.js réunit aux autres. Ses imports n'atteignent jamais
// commandes.js : le registre réuni dépend de lui, non l'inverse.
/** @type {Map<string, (charge: Object, args: Object) => Object>} */
export const COMMANDES_MOBILIER = new Map();

View file

@ -0,0 +1,9 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le registre des commandes des places — réservations, titres, pose (§ 5.8,
// § 5.9, § 8.2), que commandes.js réunit aux autres. Ses imports n'atteignent
// jamais commandes.js : le registre réuni dépend de lui, non l'inverse.
/** @type {Map<string, (charge: Object, args: Object) => Object>} */
export const COMMANDES_PLACES = new Map();

29
src/application/csv.js Normal file
View file

@ -0,0 +1,29 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le CSV tel que l'interface l'atteint (§ 13.4) : elle passe par
// l'application, jamais par src/csv. Ce module réexporte les fonctions de
// src/csv dont un écran a besoin, telles quelles, et lie le motif du CSV des
// refus au libellé français : l'interface ne choisit pas ce motif, qui est
// une donnée du fichier écrit, jamais un texte de la langue témoin.
import { exporterRefus as exporterRefusCsv } from '../csv/import.js';
import { libelle } from './libelles.js';
export { apercevoir } from '../csv/apercu.js';
export { decoder } from '../csv/encodage.js';
export { exporterParticipants } from '../csv/export.js';
export { bilanRemplacement } from '../csv/import.js';
export { cleNormalisee } from '../csv/normalisation.js';
/**
* Le CSV des refus d'un aperçu (exporterRefus de src/csv), chaque motif
* composé par libelle de son code et de ses détails.
*
* @param {import('../csv/apercu.js').Apercu} apercu
* @param {Array<Object>} [refusees] par défaut ceux de l'aperçu ; après
* l'application, resume.refusees
* @returns {Uint8Array}
*/
export function exporterRefus(apercu, refusees = apercu.refusees) {
return exporterRefusCsv(apercu, libelle, refusees);
}

View file

@ -0,0 +1,64 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du relais CSV de l'application (§ 13.4, § 10.1) : l'interface
// atteint src/csv par ce module, qui réexporte ses fonctions telles quelles
// et lie le motif du CSV des refus au libellé français. Les noms sont
// inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import * as apercu from '../csv/apercu.js';
import * as encodage from '../csv/encodage.js';
import * as exportCsv from '../csv/export.js';
import * as importCsv from '../csv/import.js';
import { decouper } from '../csv/lecture.js';
import * as normalisation from '../csv/normalisation.js';
import * as relais from './csv.js';
import { libelle } from './libelles.js';
describe('csv : le relais de src/csv pour l’interface (§ 13.4)', () => {
test('decoder, apercevoir, exporterParticipants, bilanRemplacement et cleNormalisee sont ceux de src/csv', () => {
const attendus = [
['decoder', encodage.decoder],
['apercevoir', apercu.apercevoir],
['exporterParticipants', exportCsv.exporterParticipants],
['bilanRemplacement', importCsv.bilanRemplacement],
['cleNormalisee', normalisation.cleNormalisee],
];
assert.equal(attendus.length, 5);
for (const [nom, fonction] of attendus) {
assert.equal(typeof fonction, 'function', nom);
assert.equal(relais[nom], fonction, nom);
}
assert.deepEqual(Object.keys(relais).sort(), [
'apercevoir', 'bilanRemplacement', 'cleNormalisee', 'decoder', 'exporterParticipants', 'exporterRefus',
]);
});
test("exporterRefus écrit sous l'en-tête motif exactement libelle(code, details) d'une ligne refusée NOM_ABSENT", () => {
const texte = ['nom;prenom', 'Ombrelle;Iris', ';Théo', 'Pervenche;Ondine', ''].join('\r\n');
const lu = relais.apercevoir(texte);
assert.equal(lu.refusees.length, 1);
const [{ code, brut, ...details }] = lu.refusees;
assert.equal(code, 'NOM_ABSENT');
const { enregistrements } = decouper(relais.decoder(relais.exporterRefus(lu)).texte, ';');
assert.equal(enregistrements.length, 2);
const [entete, ligne] = enregistrements;
const rang = entete.indexOf('motif');
assert.ok(rang >= 0, entete.join(';'));
assert.equal(ligne[rang], libelle('NOM_ABSENT', details));
assert.equal(ligne[entete.indexOf('ligne')], '3');
assert.ok(brut.length > 0);
});
test('exporterRefus écrit les refus reçus en second argument, à la place de ceux de l’aperçu', () => {
const lu = relais.apercevoir(['nom;exclu', 'Ombrelle;peut-être', ''].join('\r\n'));
assert.equal(lu.refusees.length, 1);
const vide = decouper(relais.decoder(relais.exporterRefus(lu, [])).texte, ';').enregistrements;
assert.deepEqual(vide, [['nom', 'exclu', 'ligne', 'motif']]);
const [entete, ligne] = decouper(relais.decoder(relais.exporterRefus(lu, lu.refusees)).texte, ';').enregistrements;
const [{ code, brut, ...details }] = lu.refusees;
assert.equal(ligne[entete.indexOf('motif')], libelle(code, details));
assert.ok(brut.length > 0);
});
});

View file

@ -0,0 +1,36 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le refus d'une commande (§ 8.2, § 14.6), que lèvent les registres de
// commandes et leurs contrôles : un code, et des détails qui portent le
// remède, details.remede, null quand aucun geste ne lève le refus.
/**
* Le refus d'une commande : son code, et des détails que l'appelant lit pour
* agir, remede compris. Le message, code et détails en JSON, sert aux traces ;
* le texte affiché vient de la table des libellés (§ 14.6).
*/
export class ErreurCommande extends Error {
/**
* @param {string} code
* @param {Object} [details]
*/
constructor(code, details = {}) {
super(`${code} ${JSON.stringify(details)}`);
this.code = code;
this.details = details;
}
}
ErreurCommande.prototype.name = 'ErreurCommande';
/**
* Le refus de code donné : ses détails, puis le remède, null sans lui.
*
* @param {string} code
* @param {Object} [details]
* @param {Object|null} [remede]
* @returns {ErreurCommande}
*/
export function refus(code, details = {}, remede = null) {
return new ErreurCommande(code, { ...details, remede });
}

149
src/application/format.js Normal file
View file

@ -0,0 +1,149 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La mise en forme des nombres, des durées, des dates et des horodatages
// (§ 5.4, § 5.10, § 11.7, § 18.4). Aucune fonction ne lit la locale du
// système : les chiffres se groupent par trois, séparés d'une espace fine
// insécable (U+202F), la décimale suit une virgule, un négatif porte le signe
// moins (U+2212). Tout texte rendu qui porte une lettre — unité, mois, ordre
// de grandeur — vient d'une clé format.… par t, en un seul appel : en langue
// témoin, il est encadré de la marque une fois. Un nombre, qui ne porte
// aucune lettre, n'y passe pas.
const ESPACE_FINE = '\u{202F}';
const MOINS = '\u{2212}';
// L'échelle des ordres de grandeur d'une durée (§ 5.10) : chaque pas en
// secondes et sa clé. Sous le premier seuil, moins d'une minute ; dès le
// dernier, plusieurs heures ; entre les deux, le pas le plus proche en
// rapport : la frontière de deux pas voisins a et b est √(a·b), comparée
// exactement, s² contre a·b.
const MOINS_D_UNE_MINUTE = 60;
const PLUSIEURS_HEURES = 5400;
const PAS = [
[120, 'format.duree.deuxMinutes'],
[300, 'format.duree.cinqMinutes'],
[600, 'format.duree.dixMinutes'],
[1200, 'format.duree.vingtMinutes'],
[1800, 'format.duree.demiHeure'],
[3600, 'format.duree.uneHeure'],
];
const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
const HORODATAGE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})[+-](\d{2}):(\d{2})$/;
// Le nombre de jours d'un mois du calendrier grégorien.
function joursDuMois(annee, mois) {
if (mois === 2) return annee % 4 === 0 && (annee % 100 !== 0 || annee % 400 === 0) ? 29 : 28;
return [4, 6, 9, 11].includes(mois) ? 30 : 31;
}
// Année, mois et jour d'une date AAAA-MM-JJ du calendrier, ou RangeError.
function lireDate(annee, mois, jour, texte) {
const lue = { annee: Number(annee), mois: Number(mois), jour: Number(jour) };
if (lue.mois < 1 || lue.mois > 12 || lue.jour < 1 || lue.jour > joursDuMois(lue.annee, lue.mois)) {
throw new RangeError(`format : ${JSON.stringify(texte)} n'est pas un jour du calendrier`);
}
return lue;
}
// Un nombre fini, ou l'erreur qui dit pourquoi il ne se met pas en forme.
function exigerFini(valeur, quoi) {
if (typeof valeur !== 'number') throw new TypeError(`format : ${quoi} doit être un nombre, reçu ${String(valeur)}`);
if (!Number.isFinite(valeur)) throw new RangeError(`format : ${quoi} non fini, ${valeur}`);
}
/**
* Les fonctions de mise en forme, dont chaque texte vient de t.
*
* @param {(cle: string, details?: Object) => string} t la traduction
* @returns {{
* nombre: (valeur: number|null, options?: {decimales?: number}) => string,
* duree: (ms: number) => string,
* ordreDeGrandeur: (secondes: number) => string,
* date: (date: string) => string,
* horodatage: (horodatage: string) => string,
* }}
*/
export function creerFormat(t) {
// Les chiffres d'un nombre fini, arrondi à decimales chiffres après la
// virgule, sans passer par la traduction.
function chiffres(valeur, decimales) {
if (!Number.isInteger(decimales) || decimales < 0 || decimales > 20) {
throw new RangeError(`format : décimales hors de 0 à 20, ${decimales}`);
}
const ecrit = Math.abs(valeur).toFixed(decimales);
if (ecrit.includes('e')) throw new RangeError(`format : ${valeur} dépasse ce qui s'écrit en chiffres`);
const [entiere, fraction] = ecrit.split('.');
const groupee = entiere.replace(/\B(?=(\d{3})+$)/g, ESPACE_FINE);
const nul = /^[0.]*$/.test(ecrit);
const signe = valeur < 0 && !nul ? MOINS : '';
return `${signe}${groupee}${fraction === undefined ? '' : `,${fraction}`}`;
}
/** Un nombre ; null, une valeur absente, s'écrit format.vide, jamais 0. */
function nombre(valeur, { decimales = 0 } = {}) {
if (valeur === null) return t('format.vide');
exigerFini(valeur, 'nombre');
return chiffres(valeur, decimales);
}
/**
* Une durée en millisecondes : sous la minute, au dixième de seconde, un
* compte rond sans décimale ; sous l'heure, minutes et secondes entières ;
* au-delà, heures et minutes entières. Un arrondi qui atteint l'unité
* suivante s'écrit dans elle.
*/
function duree(ms) {
exigerFini(ms, 'durée');
if (ms < 0) throw new RangeError(`format : durée négative, ${ms}`);
const dixiemes = Math.round(ms / 100);
if (dixiemes < 600) {
const secondes = dixiemes % 10 === 0 ? chiffres(dixiemes / 10, 0) : chiffres(dixiemes / 10, 1);
return t('format.duree.secondes', { secondes });
}
const secondes = Math.round(ms / 1000);
if (secondes < 3600) {
return t('format.duree.minutes', { minutes: Math.floor(secondes / 60), secondes: secondes % 60 });
}
const minutes = Math.round(ms / 60_000);
return t('format.duree.heures', { heures: chiffres(Math.floor(minutes / 60), 0), minutes: minutes % 60 });
}
/** L'ordre de grandeur d'une durée en secondes (§ 5.10). */
function ordreDeGrandeur(secondes) {
exigerFini(secondes, 'durée');
if (secondes < 0) throw new RangeError(`format : durée négative, ${secondes}`);
if (secondes < MOINS_D_UNE_MINUTE) return t('format.duree.moinsDUneMinute');
if (secondes >= PLUSIEURS_HEURES) return t('format.duree.plusieursHeures');
const carre = secondes * secondes;
let rang = 0;
while (rang + 1 < PAS.length && carre >= PAS[rang][0] * PAS[rang + 1][0]) rang += 1;
return t(PAS[rang][1]);
}
/** Une date AAAA-MM-JJ en lettres, « 1er » pour le premier du mois. */
function date(texte) {
const morceaux = DATE.exec(String(texte));
if (morceaux === null) throw new RangeError(`format : date AAAA-MM-JJ attendue, reçu ${JSON.stringify(texte)}`);
return t('format.date', lireDate(...morceaux.slice(1), texte));
}
/**
* Un horodatage AAAA-MM-JJTHH:MM:SS±HH:MM en lettres, à l'heure locale
* qu'il porte : la date, puis l'heure et la minute.
*/
function horodatage(texte) {
const morceaux = HORODATAGE.exec(String(texte));
if (morceaux === null) {
throw new RangeError(`format : horodatage AAAA-MM-JJTHH:MM:SS±HH:MM attendu, reçu ${JSON.stringify(texte)}`);
}
const [heure, minute, seconde, decalageH, decalageM] = morceaux.slice(4).map(Number);
if (heure > 23 || minute > 59 || seconde > 59 || decalageH > 23 || decalageM > 59) {
throw new RangeError(`format : ${JSON.stringify(texte)} n'est pas une heure du jour`);
}
return t('format.horodatage', { ...lireDate(...morceaux.slice(1, 4), texte), heure, minute });
}
return { nombre, duree, ordreDeGrandeur, date, horodatage };
}

View file

@ -0,0 +1,177 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la mise en forme (§ 5.4, § 5.10, § 11.7, § 18.4) : les valeurs
// du contrat, exactement ; « — » pour une valeur absente, jamais « 0 » ; un
// nombre non fini lève ; l'échelle des ordres de grandeur et ses frontières ;
// en langue témoin, un texte qui porte une lettre est encadré de la marque.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { creerFormat } from './format.js';
import { TABLES_APPLICATION } from './libelles.js';
import { MARQUE_TEMOIN, creerTraduction } from './traduction.js';
const { ouverture: O, fermeture: F } = MARQUE_TEMOIN;
const FINE = '\u{202F}';
const francais = creerTraduction(TABLES_APPLICATION);
const format = creerFormat(francais.t);
const temoin = creerFormat(creerTraduction(TABLES_APPLICATION, { langue: 'temoin' }).t);
describe('format : les nombres (§ 5.4, § 11.7)', () => {
test('les valeurs du contrat : « — » pour null, espace fine insécable entre les milliers, virgule décimale', () => {
assert.equal(format.nombre(null), '\u{2014}');
assert.equal(format.nombre(null), francais.t('format.vide'));
assert.equal(format.nombre(1234.5, { decimales: 1 }), `1${FINE}234,5`);
assert.equal(format.nombre(0), '0');
assert.notEqual(format.nombre(null), '0');
});
test("l'arrondi suit les décimales demandées ; les grands nombres se groupent par trois", () => {
assert.equal(format.nombre(2.25, { decimales: 0 }), '2');
assert.equal(format.nombre(2.75), '3');
assert.equal(format.nombre(999), '999');
assert.equal(format.nombre(1000), `1${FINE}000`);
assert.equal(format.nombre(1234567.891, { decimales: 2 }), `1${FINE}234${FINE}567,89`);
assert.equal(format.nombre(0.5, { decimales: 2 }), '0,50');
});
test('un négatif porte le signe moins typographique ; zéro négatif s’écrit 0', () => {
assert.equal(format.nombre(-1234), `\u{2212}1${FINE}234`);
assert.equal(format.nombre(-0), '0');
assert.equal(format.nombre(-0.04, { decimales: 1 }), '0,0');
});
test('Infinity et NaN lèvent ; une valeur qui n’est pas un nombre lève ; des décimales hors de 0 à 20 lèvent', () => {
for (const valeur of [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NaN]) {
assert.throws(() => format.nombre(valeur), RangeError, String(valeur));
}
for (const valeur of [undefined, '12', 12n]) assert.throws(() => format.nombre(valeur), TypeError, String(valeur));
for (const decimales of [-1, 1.5, 21]) assert.throws(() => format.nombre(1, { decimales }), RangeError, String(decimales));
});
test("en langue témoin, la valeur absente est encadrée ; un nombre, qui ne porte aucune lettre, ne l'est pas", () => {
assert.equal(temoin.nombre(null), `${O}\u{2014}${F}`);
assert.equal(temoin.nombre(1234.5, { decimales: 1 }), `1${FINE}234,5`);
});
});
describe('format : les durées (§ 5.10)', () => {
test('les valeurs du contrat', () => {
assert.equal(format.duree(1800), '1,8 s');
assert.equal(format.duree(150000), '2 min 30 s');
});
test("sous la minute au dixième de seconde, puis minutes et secondes, puis heures et minutes ; une part nulle se tait", () => {
assert.equal(format.duree(0), '0 s');
assert.equal(format.duree(12000), '12 s');
assert.equal(format.duree(59940), '59,9 s');
assert.equal(format.duree(59960), '1 min');
assert.equal(format.duree(120000), '2 min');
assert.equal(format.duree(3599400), '59 min 59 s');
assert.equal(format.duree(3599600), '1 h');
assert.equal(format.duree(5400000), '1 h 30 min');
assert.equal(format.duree(36000000), '10 h');
});
test('une durée négative ou non finie lève', () => {
for (const ms of [-1, Number.NaN, Number.POSITIVE_INFINITY]) assert.throws(() => format.duree(ms), RangeError, String(ms));
assert.throws(() => format.duree(null), TypeError);
});
test('en langue témoin, une durée est encadrée de la marque, une fois', () => {
assert.equal(temoin.duree(1800), `${O}1,8 s${F}`);
assert.equal(temoin.duree(150000), `${O}2 min 30 s${F}`);
});
});
describe("format : l'ordre de grandeur d'une durée (§ 5.10)", () => {
const ATTENDUS = [
[30, "moins d'une minute"],
[59, "moins d'une minute"],
[60, 'environ deux minutes'],
[848, 'environ dix minutes'],
[849, 'environ vingt minutes'],
[1200, 'environ vingt minutes'],
[1900, 'environ une demi-heure'],
[5399, 'environ une heure'],
[5400, 'plusieurs heures'],
[7200, 'plusieurs heures'],
];
test("les valeurs du contrat, frontières comprises", () => {
assert.equal(ATTENDUS.length, 10);
for (const [secondes, texte] of ATTENDUS) assert.equal(format.ordreDeGrandeur(secondes), texte, String(secondes));
assert.equal(format.ordreDeGrandeur(1200), francais.t('format.duree.vingtMinutes'));
assert.equal(francais.t('format.duree.vingtMinutes'), 'environ vingt minutes');
});
test('chaque pas a sa frontière en √(a·b) : le dernier entier de chaque pas, puis le premier du suivant', () => {
// Les pas en secondes, et le premier entier au-delà de chaque frontière.
const pas = [120, 300, 600, 1200, 1800, 3600];
const textes = pas.map((s) => format.ordreDeGrandeur(s));
assert.equal(new Set(textes).size, 6);
for (let i = 0; i + 1 < pas.length; i += 1) {
const produit = pas[i] * pas[i + 1];
let premier = Math.ceil(Math.sqrt(produit));
while (premier * premier < produit) premier += 1;
while ((premier - 1) * (premier - 1) >= produit) premier -= 1;
assert.equal(format.ordreDeGrandeur(premier - 1), textes[i], `${premier - 1} s`);
assert.equal(format.ordreDeGrandeur(premier), textes[i + 1], `${premier} s`);
}
});
test('-1, NaN et Infinity lèvent', () => {
for (const secondes of [-1, Number.NaN, Number.POSITIVE_INFINITY]) {
assert.throws(() => format.ordreDeGrandeur(secondes), RangeError, String(secondes));
}
});
test('en langue témoin, un ordre de grandeur est encadré de la marque', () => {
assert.equal(temoin.ordreDeGrandeur(1200), `${O}environ vingt minutes${F}`);
});
});
describe('format : les dates et les horodatages (§ 18.4)', () => {
test('les valeurs du contrat : la date en lettres, « 1er » pour le premier du mois', () => {
assert.equal(format.date('2026-10-06'), '6 octobre 2026');
assert.equal(format.date('2026-10-01'), '1er octobre 2026');
assert.equal(format.horodatage('2026-10-06T19:02:11-04:00'), '6 octobre 2026, 19 h 02');
});
test("chaque mois a son nom ; l'heure du matin s'écrit sans zéro de tête, à l'heure que l'horodatage porte", () => {
const mois = Array.from({ length: 12 }, (_, i) => format.date(`2031-${String(i + 1).padStart(2, '0')}-17`));
assert.equal(mois.length, 12);
assert.deepEqual(mois.map((texte) => texte.split(' ')[1]), [
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
]);
assert.equal(format.horodatage('2031-05-01T08:05:00+02:00'), '1er mai 2031, 8 h 05');
});
test("une date ou un horodatage hors de leur forme, ou hors du calendrier, lèvent", () => {
for (const date of ['2026-10-6', '06/10/2026', '2026-13-01', '2026-02-29', '2026-04-31', '2026-10-00', null]) {
assert.throws(() => format.date(date), RangeError, String(date));
}
assert.equal(format.date('2028-02-29'), '29 février 2028');
for (const horodatage of ['2026-10-06T19:02:11', '2026-10-06T24:00:00-04:00', '2026-10-06T19:60:00-04:00', '2026-10-06']) {
assert.throws(() => format.horodatage(horodatage), RangeError, horodatage);
}
});
test('en langue témoin, une date et un horodatage sont encadrés de la marque, une fois', () => {
assert.equal(temoin.date('2026-10-01'), `${O}1er octobre 2026${F}`);
assert.equal(temoin.horodatage('2026-10-06T19:02:11-04:00'), `${O}6 octobre 2026, 19 h 02${F}`);
});
});
describe('format : rien ne vient de la locale du système', () => {
test("les textes qui portent une lettre viennent des clés format.… de la table", () => {
const cles = francais.cles().filter((cle) => cle.startsWith('format.'));
assert.ok(cles.length > 0, 'aucune clé format.');
for (const cle of ['format.vide', 'format.duree.vingtMinutes']) assert.ok(cles.includes(cle), cle);
// Une table substituée change le texte rendu : il ne vient que d'elle.
const autre = creerFormat((cle, details) => (cle === 'format.vide' ? 'néant' : francais.t(cle, details)));
assert.equal(autre.nombre(null), 'néant');
});
});

193
src/application/horloge.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)
// L'horloge de l'application (§ 19.4, § 14.7) : la seule lecture de
// l'horloge du moteur d'exécution, et les seules minuteries. Hors du moteur,
// src/application et src/interface lisent l'heure et arment leurs minuteries
// par l'horloge qu'on leur injecte, jamais autrement : creerHorlogeSysteme est
// le seul code de ces deux couches qui appelle new Date, performance.now,
// setTimeout et clearTimeout. Les épreuves injectent l'horloge pilotée, où
// rien n'avance seul.
//
// Une horloge est quatre fermetures, appelables détachées de leur objet :
// horodatage() AAAA-MM-JJTHH:MM:SS±HH:MM, heure locale et décalage
// instant() millisecondes, monotone : les durées
// planifier(fn, ms) arme une minuterie, rend un identifiant opaque — un
// nombre sous le navigateur et sous l'horloge pilotée,
// un Timeout sous node —, que l'appelant ne fait que
// rendre à annuler
// annuler(id) un identifiant inconnu ou déjà parti : sans effet
/**
* @typedef {Object} Horloge
* @property {() => string} horodatage
* @property {() => number} instant
* @property {(fn: () => void, ms: number) => unknown} planifier
* @property {(id: unknown) => void} annuler
*/
const deux = (n) => String(n).padStart(2, '0');
// Un décalage en minutes à l'est de UTC, écrit ±HH:MM.
function ecrireDecalage(minutes) {
const absolu = Math.abs(minutes);
return `${minutes < 0 ? '-' : '+'}${deux(Math.floor(absolu / 60))}:${deux(absolu % 60)}`;
}
// Les contrôles communs aux deux horloges : un rappel qui se lance, un délai
// fini et positif.
function exigerMinuterie(fn, ms) {
if (typeof fn !== 'function') throw new TypeError('horloge : planifier attend une fonction');
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) {
throw new RangeError(`horloge : délai fini et positif attendu, reçu ${String(ms)}`);
}
}
/**
* L'horloge du moteur d'exécution : l'heure locale du système, son décalage,
* le compteur monotone et les minuteries du navigateur ou de node.
*
* @returns {Horloge}
*/
export function creerHorlogeSysteme() {
const horodatage = () => {
const maintenant = new Date();
const jour = `${maintenant.getFullYear()}-${deux(maintenant.getMonth() + 1)}-${deux(maintenant.getDate())}`;
const heure = `${deux(maintenant.getHours())}:${deux(maintenant.getMinutes())}:${deux(maintenant.getSeconds())}`;
return `${jour}T${heure}${ecrireDecalage(-maintenant.getTimezoneOffset())}`;
};
const instant = () => performance.now();
const planifier = (fn, ms) => {
exigerMinuterie(fn, ms);
return setTimeout(fn, ms);
};
const annuler = (id) => {
clearTimeout(id);
};
return { horodatage, instant, planifier, annuler };
}
// --- Le calendrier de l'horloge pilotée ---------------------------------------
//
// L'horloge pilotée compte en millisecondes depuis 1970-01-01T00:00:00 UTC et
// convertit par l'arithmétique du calendrier grégorien proleptique, sans lire
// l'horloge du système. Les jours se comptent depuis cette origine.
const MS_PAR_JOUR = 86_400_000;
const FORME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})([+-])(\d{2}):(\d{2})$/;
// Le nombre de jours depuis 1970-01-01 d'une date civile.
function joursDepuisOrigine(annee, mois, jour) {
const a = mois <= 2 ? annee - 1 : annee;
const ere = Math.floor(a / 400);
const anneeDEre = a - ere * 400;
const jourDAnnee = Math.floor((153 * (mois + (mois > 2 ? -3 : 9)) + 2) / 5) + jour - 1;
const jourDEre = anneeDEre * 365 + Math.floor(anneeDEre / 4) - Math.floor(anneeDEre / 100) + jourDAnnee;
return ere * 146_097 + jourDEre - 719_468;
}
// La date civile du jour compté depuis 1970-01-01.
function dateCivile(jours) {
const z = jours + 719_468;
const ere = Math.floor(z / 146_097);
const jourDEre = z - ere * 146_097;
const anneeDEre = Math.floor(
(jourDEre - Math.floor(jourDEre / 1460) + Math.floor(jourDEre / 36_524) - Math.floor(jourDEre / 146_096)) / 365,
);
const jourDAnnee = jourDEre - (365 * anneeDEre + Math.floor(anneeDEre / 4) - Math.floor(anneeDEre / 100));
const moisDecale = Math.floor((5 * jourDAnnee + 2) / 153);
const jour = jourDAnnee - Math.floor((153 * moisDecale + 2) / 5) + 1;
const mois = moisDecale < 10 ? moisDecale + 3 : moisDecale - 9;
return { annee: anneeDEre + ere * 400 + (mois <= 2 ? 1 : 0), mois, jour };
}
// Un horodatage de départ lu : l'instant UTC en millisecondes et le décalage
// en minutes à l'est de UTC. Une date hors du calendrier lève.
function lireDepart(depart) {
const morceaux = FORME.exec(String(depart));
if (morceaux === null) throw new RangeError(`horloge : départ AAAA-MM-JJTHH:MM:SS±HH:MM attendu, reçu ${depart}`);
const [annee, mois, jour, heure, minute, seconde] = morceaux.slice(1, 7).map(Number);
const decalage = (morceaux[7] === '-' ? -1 : 1) * (Number(morceaux[8]) * 60 + Number(morceaux[9]));
const jours = joursDepuisOrigine(annee, mois, jour);
const relue = dateCivile(jours);
if (mois < 1 || mois > 12 || relue.mois !== mois || relue.jour !== jour || heure > 23 || minute > 59 || seconde > 59) {
throw new RangeError(`horloge : ${depart} n'est pas un instant du calendrier`);
}
const local = jours * MS_PAR_JOUR + ((heure * 60 + minute) * 60 + seconde) * 1000;
return { utc: local - decalage * 60_000, decalage };
}
/**
* Une horloge où rien n'avance seul. avancer(ms) est synchrone : il
* déclenche les minuteries échues dans l'ordre de leur échéance, puis de
* leur création, chacune à l'instant de son échéance — horodatage() et
* instant() y rendent cette échéance — ; une minuterie qu'un rappel arme et
* qui échoit dans la fenêtre part dans le même avancer ; à son retour,
* l'horloge a avancé de ms exactement. Il n'attend aucune promesse : l'épreuve
* attend elle-même ce que les rappels lancent. Un rappel qui lève arrête
* avancer à son échéance, et l'erreur remonte.
*
* horodatage() garde le décalage du départ ; instant() part de 0.
*
* @param {{depart?: string}} [options]
* @returns {Horloge & {avancer: (ms: number) => undefined}}
* @throws {RangeError} un départ hors de sa forme ou du calendrier
*/
export function creerHorlogePilotee({ depart = '2026-10-06T19:00:00-04:00' } = {}) {
const { utc, decalage } = lireDepart(depart);
let ecoule = 0;
let prochainId = 1;
// Les minuteries en attente, par identifiant : échéance et rappel. Les
// identifiants croissent avec la création.
const enAttente = new Map();
const horodatage = () => {
const local = utc + ecoule + decalage * 60_000;
const jours = Math.floor(local / MS_PAR_JOUR);
const { annee, mois, jour } = dateCivile(jours);
const secondes = Math.floor((local - jours * MS_PAR_JOUR) / 1000);
const heure = `${deux(Math.floor(secondes / 3600))}:${deux(Math.floor(secondes / 60) % 60)}:${deux(secondes % 60)}`;
return `${String(annee).padStart(4, '0')}-${deux(mois)}-${deux(jour)}T${heure}${ecrireDecalage(decalage)}`;
};
const instant = () => ecoule;
const planifier = (fn, ms) => {
exigerMinuterie(fn, ms);
const id = prochainId;
prochainId += 1;
enAttente.set(id, { echeance: ecoule + ms, fn });
return id;
};
const annuler = (id) => {
enAttente.delete(id);
};
// La prochaine minuterie échue au plus tard à fin : la plus petite
// échéance, puis le plus petit identifiant ; null quand il n'y en a pas.
function prochaine(fin) {
let choisie = null;
for (const [id, { echeance }] of enAttente) {
if (echeance > fin) continue;
if (choisie === null || echeance < choisie.echeance || (echeance === choisie.echeance && id < choisie.id)) {
choisie = { id, echeance };
}
}
return choisie;
}
function avancer(ms) {
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) {
throw new RangeError(`horloge : avancer attend un délai fini et positif, reçu ${String(ms)}`);
}
const fin = ecoule + ms;
for (let suivante = prochaine(fin); suivante !== null; suivante = prochaine(fin)) {
const { fn } = enAttente.get(suivante.id);
enAttente.delete(suivante.id);
ecoule = suivante.echeance;
fn();
}
ecoule = fin;
return undefined;
}
return { horodatage, instant, planifier, annuler, avancer };
}

View file

@ -0,0 +1,187 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de l'horloge (§ 19.4, § 14.7, § 14.14) : la pilotée déclenche ses
// minuteries dans l'ordre de leur échéance, chacune à son instant, sans rien
// attendre du temps réel ; la système porte l'heure locale et son décalage, et
// aucune épreuve n'attend qu'une de ses minuteries parte. Les quatre lectures
// de chaque horloge répondent détachées de leur objet.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { creerHorlogePilotee, creerHorlogeSysteme } from './horloge.js';
const MINUTE = 60_000;
const FORME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})([+-])(\d{2}):(\d{2})$/;
describe('horloge pilotée : les minuteries (§ 19.4)', () => {
test("deux minuteries à 10 et 5 min : avancer(6 min) déclenche la seconde seule, avancer(4 min) la première", () => {
const horloge = creerHorlogePilotee();
const parties = [];
horloge.planifier(() => parties.push('dix'), 10 * MINUTE);
horloge.planifier(() => parties.push('cinq'), 5 * MINUTE);
assert.equal(horloge.avancer(6 * MINUTE), undefined);
assert.deepEqual(parties, ['cinq']);
horloge.avancer(4 * MINUTE);
assert.deepEqual(parties, ['cinq', 'dix']);
});
test('une minuterie annulée ne part jamais', () => {
const horloge = creerHorlogePilotee();
const parties = [];
const id = horloge.planifier(() => parties.push('annulee'), MINUTE);
horloge.planifier(() => parties.push('gardee'), MINUTE);
horloge.annuler(id);
horloge.avancer(10 * MINUTE);
assert.deepEqual(parties, ['gardee']);
});
test("un rappel lit horodatage() et instant() à son échéance, non à la fin de la fenêtre", () => {
const horloge = creerHorlogePilotee();
const lus = [];
horloge.planifier(() => lus.push([horloge.horodatage(), horloge.instant()]), 5 * MINUTE);
horloge.avancer(10 * MINUTE);
assert.deepEqual(lus, [['2026-10-06T19:05:00-04:00', 5 * MINUTE]]);
assert.equal(horloge.horodatage(), '2026-10-06T19:10:00-04:00');
assert.equal(horloge.instant(), 10 * MINUTE);
});
test("une minuterie de 3 min qu'arme un rappel parti à 5 min part dans le même avancer(10 min), à 8 min", () => {
const horloge = creerHorlogePilotee();
const parties = [];
horloge.planifier(() => {
parties.push(['premiere', horloge.instant()]);
horloge.planifier(() => parties.push(['armee', horloge.instant()]), 3 * MINUTE);
}, 5 * MINUTE);
horloge.avancer(10 * MINUTE);
assert.deepEqual(parties, [
['premiere', 5 * MINUTE],
['armee', 8 * MINUTE],
]);
assert.equal(horloge.instant(), 10 * MINUTE);
});
test("à échéance égale, l'ordre de création ; une minuterie hors de la fenêtre attend l'avancer suivant", () => {
const horloge = creerHorlogePilotee();
const parties = [];
horloge.planifier(() => parties.push('b'), 2 * MINUTE);
horloge.planifier(() => parties.push('a1'), MINUTE);
horloge.planifier(() => parties.push('a2'), MINUTE);
horloge.planifier(() => parties.push('c'), 3 * MINUTE);
horloge.avancer(2 * MINUTE);
assert.deepEqual(parties, ['a1', 'a2', 'b']);
horloge.avancer(MINUTE - 1);
assert.deepEqual(parties, ['a1', 'a2', 'b']);
horloge.avancer(1);
assert.deepEqual(parties, ['a1', 'a2', 'b', 'c']);
});
test('avancer rend undefined ; un ms négatif ou non fini lève', () => {
const horloge = creerHorlogePilotee();
assert.equal(horloge.avancer(0), undefined);
for (const ms of [-1, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) {
assert.throws(() => horloge.avancer(ms), RangeError, String(ms));
}
assert.equal(horloge.instant(), 0);
});
test("horodatage avance d'autant, décalage compris, par-delà minuit, la fin du mois et de l'année", () => {
const horloge = creerHorlogePilotee();
assert.equal(horloge.horodatage(), '2026-10-06T19:00:00-04:00');
horloge.avancer(90 * MINUTE + 7_000);
assert.equal(horloge.horodatage(), '2026-10-06T20:30:07-04:00');
horloge.avancer(4 * 60 * MINUTE);
assert.equal(horloge.horodatage(), '2026-10-07T00:30:07-04:00');
const fin = creerHorlogePilotee({ depart: '2028-12-31T23:59:30+05:30' });
fin.avancer(45_000);
assert.equal(fin.horodatage(), '2029-01-01T00:00:15+05:30');
const bissextile = creerHorlogePilotee({ depart: '2028-02-28T12:00:00+00:00' });
bissextile.avancer(24 * 60 * MINUTE);
assert.equal(bissextile.horodatage(), '2028-02-29T12:00:00+00:00');
});
test('instant est monotone, et commence à zéro', () => {
const horloge = creerHorlogePilotee();
const lus = [horloge.instant()];
for (const pas of [0, 1, 250, 0, 60_000]) {
horloge.avancer(pas);
lus.push(horloge.instant());
}
assert.equal(lus[0], 0);
for (let i = 1; i < lus.length; i += 1) assert.ok(lus[i] >= lus[i - 1], `${lus[i - 1]} puis ${lus[i]}`);
assert.equal(lus.at(-1), 60_251);
});
test("rien n'attend le temps réel : un avancer d'une semaine se rend sur-le-champ", () => {
const horloge = creerHorlogePilotee();
let parties = 0;
for (let i = 1; i <= 100; i += 1) horloge.planifier(() => (parties += 1), i * 60 * MINUTE);
horloge.avancer(7 * 24 * 60 * MINUTE);
assert.equal(parties, 100);
});
test('un départ hors de la forme AAAA-MM-JJTHH:MM:SS±HH:MM lève', () => {
for (const depart of ['2026-10-06 19:00:00', '2026-13-01T00:00:00+00:00', '2026-02-30T00:00:00+00:00', 'demain']) {
assert.throws(() => creerHorlogePilotee({ depart }), RangeError, depart);
}
});
test('planifier refuse un délai négatif ou non fini, et un rappel qui ne se lance pas', () => {
const horloge = creerHorlogePilotee();
assert.throws(() => horloge.planifier(() => {}, -5), RangeError);
assert.throws(() => horloge.planifier(() => {}, Number.NaN), RangeError);
assert.throws(() => horloge.planifier('rappel', 5), TypeError);
});
});
describe('horloge système (§ 19.4, § 14.14)', () => {
test("horodatage a la forme AAAA-MM-JJTHH:MM:SS±HH:MM, l'heure courante, et porte le décalage local", () => {
const horloge = creerHorlogeSysteme();
const lu = horloge.horodatage();
const morceaux = FORME.exec(lu);
assert.ok(morceaux !== null, lu);
const instant = Date.parse(lu);
assert.ok(Math.abs(instant - Date.now()) < 5_000, `${lu} loin de l'heure courante`);
const [, , , , , , , signe, heures, minutes] = morceaux;
const decalage = (signe === '-' ? -1 : 1) * (Number(heures) * 60 + Number(minutes));
assert.equal(decalage, -new Date(instant).getTimezoneOffset());
});
test("instant est monotone ; planifier rend un identifiant qu'annuler accepte, sans qu'aucune épreuve n'attende qu'il parte", () => {
const horloge = creerHorlogeSysteme();
const avant = horloge.instant();
assert.ok(horloge.instant() >= avant);
let partie = false;
const id = horloge.planifier(() => (partie = true), 60 * MINUTE);
assert.notEqual(id, undefined);
assert.equal(horloge.annuler(id), undefined);
assert.equal(horloge.annuler(id), undefined, 'un identifiant déjà annulé est sans effet');
assert.equal(horloge.annuler('inconnu'), undefined);
assert.equal(partie, false);
});
});
describe('horloges : quatre fermetures appelables détachées', () => {
test('la pilotée répond détachée comme sur son objet ; une minuterie annulée détachée ne part pas', () => {
const horloge = creerHorlogePilotee();
const { horodatage, instant, planifier, annuler } = horloge;
const parties = [];
const id = planifier(() => parties.push('annulee'), MINUTE);
planifier(() => parties.push('gardee'), MINUTE);
annuler(id);
horloge.avancer(2 * MINUTE);
assert.deepEqual(parties, ['gardee']);
assert.equal(instant(), horloge.instant());
assert.equal(horodatage(), horloge.horodatage());
assert.equal(horodatage(), '2026-10-06T19:02:00-04:00');
});
test('la système répond détachée : armer puis annuler', () => {
const { horodatage, instant, planifier, annuler } = creerHorlogeSysteme();
assert.match(horodatage(), FORME);
assert.equal(typeof instant(), 'number');
const id = planifier(() => {
throw new Error('une minuterie annulée est partie');
}, 60 * MINUTE);
assert.equal(annuler(id), undefined);
});
});

View file

@ -1,657 +1,70 @@
// © 2026 TechnoLibre (http://www.technolibre.ca) // © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La table des textes de l'application (§ 14.6) : chaque code que lèvent ou // L'assemblage des textes de l'application (§ 14.6) : chaque code que lèvent
// rendent src/stockage, src/csv et src/application — refus, avertissements, // ou rendent src/stockage, src/csv et src/application — refus, avertissements,
// raisons, causes et remèdes de leurs détails — et chaque libellé figé d'une // raisons, causes et remèdes de leurs détails —, chaque libellé figé d'une
// entrée ou d'un jalon du journal a ici son texte français, qu'une fonction // entrée ou d'un jalon du journal, et les textes de la mise en forme ont leur
// compose de ses détails. Un code sans texte lève : l'écran ne montre jamais // texte français dans l'une des tables de libelles/, une par domaine. Ce
// un code brut. Une valeur codée d'un détail — état, champ, mode, échec — // module les réunit par le mécanisme de traduction (traduction.js), dans
// que la table ne connaît pas lève aussi, au lieu de se taire. Une raison // l'ordre alphabétique de leurs fichiers ; aucune table ne l'importe.
// qu'un texte ne traite pas à part ne lève jamais : une phrase générique la //
// nomme, par son texte quand la table en porte un, telle quelle sinon. Une // Un code sans texte lève : l'écran ne montre jamais un code brut. Une valeur
// cause que nomme le système, EROFS, ENOENT ou TypeMismatchError, n'est pas // codée d'un détail — état, champ, mode, échec — que la table ne connaît pas
// un code de l'application : les textes la citent telle quelle, entre // lève aussi, au lieu de se taire.
// parenthèses ; une cause que la table porte s'y lit par son texte.
// //
// Un libellé d'entrée se compose une fois, à l'écriture du geste, et le // Un libellé d'entrée se compose une fois, à l'écriture du geste, et le
// journal en garde le texte, jamais le code (§ 8.2) : changer un texte ici ne // journal en garde le texte, jamais le code (§ 8.2) : changer un texte ici ne
// change aucun libellé déjà écrit. // change aucun libellé déjà écrit. libelle compose toujours en français : un
// // texte qui devient une donnée — libellé figé, motif du CSV des refus — ne
// Les textes ne lisent ni l'horloge ni la langue du système : un horodatage // passe jamais par la langue témoin.
// se met en mots à partir de sa seule forme, AAAA-MM-JJTHH:MM:SS±HH:MM. import { TABLE as CSV } from './libelles/csv.js';
import { TABLE as EVENEMENTS } from './libelles/evenements.js';
import { TABLE as FORMAT } from './libelles/format.js';
import { TABLE as GENERATION } from './libelles/generation.js';
import { TABLE as MOBILIER } from './libelles/mobilier.js';
import { TABLE as MOTEUR } from './libelles/moteur.js';
import { TABLE as PARTICIPANTS } from './libelles/participants.js';
import { TABLE as PLACES } from './libelles/places.js';
import { TABLE as SEANCE } from './libelles/seance.js';
import { TABLE as STOCKAGE } from './libelles/stockage.js';
import { creerTraduction } from './traduction.js';
// Deux chaînes, comparées unité UTF-16 par unité. /** Les tables de libelles/, dans l'ordre alphabétique de leurs fichiers. */
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0); export const TABLES_APPLICATION = Object.freeze([
CSV,
const NOMS_DES_ETATS = new Map([ EVENEMENTS,
['brouillon', 'Brouillon'], FORMAT,
['propose', 'Proposé'], GENERATION,
['retenu', 'Retenu'], MOBILIER,
['bloque', 'Bloqué'], MOTEUR,
PARTICIPANTS,
PLACES,
SEANCE,
STOCKAGE,
]); ]);
// Les champs d'un participant qu'une saisie modifie, dans l'ordre du fichier. const FRANCAIS = creerTraduction(TABLES_APPLICATION);
const NOMS_DES_CHAMPS = new Map([
['nom', 'nom'],
['prenom', 'prénom'],
['appartenance', 'appartenance'],
['courriel', 'courriel'],
['titrePressenti', 'titre pressenti'],
['notes', 'notes'],
]);
const MOIS = [ // Un code : des capitales, des chiffres, des soulignés entre deux mots. Un
'janvier', // libellé, préfixé du nom de sa table, porte au moins un point.
'février', const FORME_CODE = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/;
'mars',
'avril',
'mai',
'juin',
'juillet',
'août',
'septembre',
'octobre',
'novembre',
'décembre',
];
// Valeur d'une table de noms, ou RangeError qui la nomme. /** Les clés de forme code, seules, triées par unités UTF-16 croissantes. */
function nomDans(table, valeur, quoi) { export const CODES = Object.freeze(FRANCAIS.cles().filter((cle) => FORME_CODE.test(cle)));
if (!table.has(valeur)) throw new RangeError(`libelle : ${quoi} inconnu ${JSON.stringify(valeur)}`);
return table.get(valeur);
}
const nomEtat = (etat) => nomDans(NOMS_DES_ETATS, etat, 'état');
const nomChamp = (champ) => nomDans(NOMS_DES_CHAMPS, champ, 'champ');
// Un texte cité entre guillemets français.
const cite = (texte) => `« ${texte} »`;
// Une cause entre parenthèses, rien quand elle manque : son texte quand la
// table en porte un, sinon la cause telle que le système la nomme.
function causeEntreParentheses(cause) {
if (cause === null || cause === undefined || cause === '') return '';
return ` (${TEXTES.has(cause) ? texteDe(cause) : cause})`;
}
// Le chemin de l'élément fautif, rien quand il manque ou désigne la racine.
const element = (chemin) => (typeof chemin === 'string' && chemin !== '' ? ` (${chemin})` : '');
// Un compte et son nom, au singulier jusqu'à un, comme le veut le français.
const compte = (n, singulier, pluriel) => `${n} ${n <= 1 ? singulier : pluriel}`;
// Une énumération : « a », « a et b », « a, b et c ».
function enumeration(termes) {
if (termes.length <= 1) return termes.join('');
return `${termes.slice(0, -1).join(', ')} et ${termes.at(-1)}`;
}
// Des numéros de proposition, un illisible nommé comme tel.
const numeros = (ids) => enumeration(ids.map((id) => (id === null ? 'sans numéro lisible' : `n° ${id}`)));
// La première lettre en capitale.
const capitale = (texte) => texte.charAt(0).toUpperCase() + texte.slice(1);
// Un horodatage AAAA-MM-JJTHH:MM:SS±HH:MM en mots, « le 17 mai 2031 à 8 h 05 »,
// à l'heure qu'il porte ; une autre forme se cite telle quelle.
const HORODATAGE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):\d{2}[+-]\d{2}:\d{2}$/;
function moment(horodatage) {
const morceaux = HORODATAGE.exec(String(horodatage));
if (morceaux === null) return String(horodatage);
const [annee, mois, jour, heure, minute] = morceaux.slice(1).map(Number);
if (mois < 1 || mois > 12) return String(horodatage);
const quantieme = jour === 1 ? '1er' : String(jour);
return `le ${quantieme} ${MOIS[mois - 1]} ${annee} à ${heure} h ${String(minute).padStart(2, '0')}`;
}
const depuisQuand = (depuis) => (depuis === null || depuis === undefined ? '' : ` depuis ${moment(depuis)}`);
// Le chemin affichable d'une racine, reçue entière ou par son seul chemin.
const cheminDe = (racine) => (typeof racine === 'string' ? racine : racine?.chemin);
// Le remède commun aux refus d'encodage (§ 10.1).
const REMEDE_UTF8 = 'Réenregistrez le fichier en UTF-8 depuis le tableur, puis importez-le de nouveau.';
const refusEncodage = (constat) => () => `${constat} ${REMEDE_UTF8}`;
// Une raison codée en une phrase : son texte quand la table en porte un et
// que les détails reçus lui suffisent ; sinon une phrase générique qui la
// cite telle quelle. Un texte qui attend ses propres détails lève ou rend
// « undefined » sans eux : la phrase générique le remplace alors.
function phraseDeRaison(raison, details = {}) {
const generique = `La raison donnée est ${cite(String(raison))}.`;
if (!TEXTES.has(raison)) return generique;
try {
const texte = texteDe(raison, details);
return typeof texte === 'string' && !texte.includes('undefined') ? texte : generique;
} catch {
return generique;
}
}
// La raison d'un fichier d'état illisible, en une phrase.
function raisonIllisible({ raison, ...details }) {
if (raison === 'ABSENT') return "Le fichier d'état manque, à côté de son historique.";
return phraseDeRaison(raison, details);
}
// Les secours d'un fichier d'état illisible, quand l'erreur les nomme.
function secoursDe(secours) {
if (secours === undefined || secours === null) return '';
const offerts = [];
if (secours.precedent !== null) offerts.push(`la version précédente du fichier (révision ${secours.precedent})`);
if (secours.journal !== null) offerts.push(`le dernier instant de l'historique (révision ${secours.journal})`);
if (offerts.length === 0) return ' Aucune version de secours ne se lit.';
return ` ${capitale(enumeration(offerts))} ${offerts.length === 1 ? 'peut' : 'peuvent'} se rouvrir.`;
}
// Ce qu'une dérive touche, du retenu aux propositions, et l'accord du verbe.
function touchees({ ids, retenu }) {
const parties = [];
if (retenu) parties.push('le placement retenu');
if (ids.length === 1) parties.push(`la proposition n° ${ids[0]}`);
if (ids.length > 1) parties.push(`les propositions n° ${enumeration(ids.map(String))}`);
return { texte: capitale(enumeration(parties)), pluriel: (retenu ? 1 : 0) + ids.length > 1 };
}
// Les titres de place qu'un geste sur une personne touche, quand il y en a.
const titresDe = (n, singulier, pluriel) =>
n > 0 ? `, et ${compte(n, 'titre de place', 'titres de place')} ${n <= 1 ? singulier : pluriel}` : '';
// Ce qu'un refus d'écriture nomme : le fichier, son dossier, la cause.
const ouEcrire = ({ chemin, dossier, cause }) =>
`${cite(chemin)} dans le dossier ${cite(dossier)}${causeEntreParentheses(cause)}`;
const TEXTES = new Map([
// --- Le système de fichiers et le dépôt (src/stockage/types.js) ---------
[
'ABSENT',
({ chemin }) =>
typeof chemin === 'string' ? `Le fichier ${cite(chemin)} est introuvable.` : "Le fichier d'état manque.",
],
['ECRITURE', (details) => `L'écriture de ${ouEcrire(details)} a échoué ; le fichier d'avant reste intact.`],
['LECTURE', (details) => `La lecture de ${ouEcrire(details)} est refusée.`],
['EXISTE', ({ chemin }) => `Un fichier ${cite(chemin)} existe déjà.`],
['CHEMIN_REFUSE', ({ chemin }) => `Le chemin ${cite(String(chemin))} est refusé.`],
[
'ETAT_ILLISIBLE',
(details) => {
const sujet = typeof details.base === 'string' ? `Le fichier d'état de ${cite(details.base)}` : 'Le fichier';
return `${sujet} ne s'ouvre pas. ${raisonIllisible(details)}${secoursDe(details.secours)}`;
},
],
['CORRECTIF', ({ rang }) => `Un correctif de l'historique ne s'applique pas (opération ${rang}).`],
[
'CHEMIN_TROP_LONG',
({ racine }) =>
`Le dossier ${cite(cheminDe(racine))} est trop profond : aucun nom de fichier n'y tient ` +
'sous la longueur de chemin que Windows accepte.',
],
[
'IDENTIFIANT_PRESENT',
({ base }) =>
`Cet événement est déjà dans ce dossier, sous ${cite(base)} : ` +
"l'y placer une seconde fois en ferait deux copies de même identifiant.",
],
[
'VERROU_PRIS',
({ depuis, vivant }) => {
if (vivant === true) {
return `Une autre séance modifie cet événement${depuisQuand(depuis)} : il reste en lecture ici.`;
}
if (vivant === false) {
return (
`Une séance interrompue a laissé son verrou${depuisQuand(depuis)}, et il n'a pas pu se reprendre : ` +
"l'événement reste en lecture."
);
}
return (
`Un verrou dont la séance ne se laisse pas vérifier — autre poste, verrou illisible, ou séance de ce poste dont la vie ne se prouve pas — tient cet événement${depuisQuand(depuis)} : ` +
'il reste en lecture ici, et rien ne le reprend seul.'
);
},
],
[
'LECTURE_SEULE',
({ raison }) => {
if (raison === 'BLOQUE') {
return `${texteDe('BLOQUE')} Rien ne peut changer ; « Débloquer » le rend de nouveau modifiable.`;
}
if (raison === 'FORMAT' || raison === 'FORMAT_PLUS_RECENT') {
return `${texteDe(raison)} Il s'ouvre en lecture seule, et aucun geste ne le modifie ici.`;
}
if (raison === 'ENCODAGE') {
return (
`Le fichier d'état a changé sur le disque. ${texteDe('ENCODAGE')} ` +
"L'événement reste en lecture seule ; rouvert par une version de secours, son premier geste " +
"range ce fichier dans la corbeille avant d'écrire l'état."
);
}
return `${phraseDeRaison(raison)} L'événement reste en lecture seule, et aucun geste ne le modifie ici.`;
},
],
[
'ETAT_NON_ECRIT',
(details) => `Le geste est inscrit à l'historique, mais l'état ${ouEcrire(details)} n'a pas pu s'écrire.`,
],
[
'CORBEILLE_SATUREE',
({ dossier }) =>
`La corbeille ${cite(dossier)} a reçu trop de suppressions dans la même seconde : ` +
'la suppression se refait une seconde plus tard.',
],
[
'COMPTEUR_SATURE',
({ compteur }) =>
`Les numéros ${compteur === 'proposition' ? 'de proposition' : `du compteur ${cite(compteur)}`} sont épuisés : ` +
'rien de neuf ne se numérote plus dans cet événement.',
],
['NON_DISPONIBLE', ({ cause }) => `Cette commande n'est pas offerte ici${causeEntreParentheses(cause)}.`],
// --- Les raisons d'un fichier illisible ----------------------------------
['VIDE', () => 'Le fichier est vide.'],
['JSON', () => "Le texte n'est pas du JSON valide."],
['FORME', ({ chemin }) => `Une valeur sort de sa règle${element(chemin)}.`],
['COMPTES', ({ chemin }) => `Un compte de l'en-tête contredit sa liste${element(chemin)}.`],
['REFERENCE', ({ chemin }) => `Une réservation ou un titre désigne ce qui n'existe pas${element(chemin)}.`],
['FORMAT_INCONNU', () => "Le format du fichier n'est pas reconnu."],
[
'FORMAT_PLUS_RECENT',
({ format }) => {
const quel = Number.isInteger(format) ? ` (format ${format})` : '';
return `Ce fichier vient d'une version plus récente du logiciel${quel}.`;
},
],
[
'ENCODAGE',
() => "Ses octets ne sont pas de l'UTF-8 : un éditeur l'a sans doute réenregistré dans un autre encodage.",
],
// --- Les fautes d'un placement, et ses raisons de dérive (§ 8.9, § 9) ----
[
'LONGUEUR',
({ tour, table, declare, ecrit }) => {
if (tour === null) return `Le placement déclare ${compte(declare, 'tour', 'tours')} et en porte ${ecrit}.`;
if (table === null) {
return `Au tour ${tour}, le placement déclare ${compte(declare, 'table', 'tables')} et en porte ${ecrit}.`;
}
return (
`Au tour ${tour}, la table ${table} reçoit ${compte(ecrit, 'personne', 'personnes')} ` +
`pour ${compte(declare, 'place', 'places')}.`
);
},
],
['DOUBLON', ({ tour, participant }) => `Au tour ${tour}, la personne n° ${participant} est placée plus d'une fois.`],
[
'INCONNU',
({ tour, participant }) => `Au tour ${tour}, la personne n° ${participant} est placée sans être déclarée.`,
],
['MANQUANT', ({ tour, participant }) => `Au tour ${tour}, la personne n° ${participant} n'est placée nulle part.`],
['IDENTIFIANT_REPETE', () => 'Une autre proposition porte déjà ce numéro.'],
['IDENTIFIANT_HORS_COMPTEUR', () => 'Ce numéro de proposition dépasse le compteur du fichier.'],
['PARTICIPANT_EXCLU', ({ participant }) => `La personne n° ${participant} est exclue depuis.`],
['PARTICIPANT_SUPPRIME', ({ participant }) => `La personne n° ${participant} a été supprimée depuis.`],
[
'PARTICIPANT_NON_PLACE',
({ participant }) => `La personne n° ${participant}, ajoutée ou réintégrée depuis, n'y est pas placée.`,
],
['TABLE_SUPPRIMEE', ({ table }) => `La table d'identifiant ${table} a été supprimée depuis.`],
['TABLE_AJOUTEE', ({ table }) => `La table d'identifiant ${table} a été ajoutée depuis.`],
[
'CAPACITE_CHANGEE',
({ table, avant, maintenant }) =>
`La table d'identifiant ${table} est passée de ${avant} à ${compte(maintenant, 'place', 'places')}.`,
],
['TOURS_CHANGES', ({ avant, maintenant }) => `Le nombre de tours est passé de ${avant} à ${maintenant}.`],
// --- Ce que l'ouverture annonce (§ 8.6, § 8.8, § 8.9, § 9) ---------------
['RETENU_FAUTIF', () => 'Le placement retenu se contredit : la version précédente du fichier peut le reprendre.'],
['JOURNAL_REPRIS', () => "Le dernier geste, inscrit à l'historique mais pas dans l'état, est repris."],
[
'JOURNAL_EN_AVANCE_DE',
({ n }) =>
`L'historique compte ${compte(n, 'geste', 'gestes')} de plus que l'état : ` +
"l'état du disque est affiché, et le prochain geste inscrit une reprise.",
],
[
'JOURNAL_INCOMPLET',
({ etat, journal }) =>
`L'historique s'arrête à la révision ${journal}, l'état est à la révision ${etat} : ` +
'le prochain geste inscrit une reprise.',
],
[
'JOURNAL_ECARTEES',
({ n }) =>
`${compte(n, 'entrée', 'entrées')} de l'historique ${n <= 1 ? 'est écartée' : 'sont écartées'}, ` +
'à partir de la première ligne illisible.',
],
[
'JOURNAL_RECREE',
() =>
"Le fichier de l'historique avait disparu du dossier : il est réécrit en entier, " +
'tel que la séance le connaissait.',
],
[
'JOURNAL_FORMAT_PLUS_RECENT',
({ format }) =>
`L'historique vient d'une version plus récente du logiciel (format ${format}) : ` +
"il ne se lit pas, et l'événement reste en lecture seule.",
],
[
'JOURNAL_ILLISIBLE',
({ raison }) =>
`L'historique à côté de ce fichier ne se lit pas. ${phraseDeRaison(raison)} ` +
"Il est laissé tel quel, et le prochain geste commence l'historique dans un nouveau fichier.",
],
[
'JOURNAL_ETRANGER',
({ evenement }) =>
`L'historique à côté de ce fichier appartient à un autre événement (${evenement}) : il est ignoré.`,
],
[
'PROPOSITIONS_FAUTIVES',
({ ids }) =>
ids.length <= 1
? `Une proposition incohérente est écartée (${numeros(ids)}).`
: `${ids.length} propositions incohérentes sont écartées (${numeros(ids)}).`,
],
[
'PROPOSITIONS_MANQUANTES',
({ n }) =>
`${compte(n, "proposition que l'en-tête annonce manque", "propositions que l'en-tête annonce manquent")} ` +
'dans le fichier : celles qu\'il porte sont gardées.',
],
[
'DERIVE',
(details) => {
const { texte, pluriel } = touchees(details);
return pluriel
? `${texte} ne décrivent plus la liste ou les tables actuelles : ils sont gardés, et rien n'est effacé.`
: `${texte} ne décrit plus la liste ou les tables actuelles : il est gardé, et rien n'est effacé.`;
},
],
// --- Le dossier de travail, les réglages locaux (§ 8.5, § 8.6, § 8.8) ----
['NON_PUBLIE', () => "Le dossier de l'exécutable n'est pas connu : les événements se rangent dans les Documents."],
[
'DONNEES_APPLICATIVES',
() => "L'exécutable est rangé sous les données applicatives : les événements se rangent dans les Documents.",
],
[
'SONDE_ECHOUEE',
({ cause }) =>
`Le dossier data à côté de l'exécutable refuse l'écriture${causeEntreParentheses(cause)} : ` +
'les événements se rangent dans les Documents.',
],
// Une cause de la sonde, que les textes citent entre parenthèses.
['RELECTURE_DIFFERENTE', () => "le fichier d'essai relu ne porte pas ce qui vient d'y être écrit"],
[
'PORTABLE_NON_INSCRIPTIBLE',
({ racine, evenements, cause }) => {
const porte =
evenements === null
? 'porte peut-être des événements'
: `porte ${compte(evenements, 'événement', 'événements')}`;
return `Le dossier ${cite(cheminDe(racine))} ${porte}, mais refuse l'écriture${causeEntreParentheses(cause)}.`;
},
],
[
'DOCUMENTS_NON_INSCRIPTIBLE',
({ racine, cause }) =>
`Le dossier de travail ${cite(cheminDe(racine))} refuse l'écriture${causeEntreParentheses(cause)} : ` +
"aucun geste ne s'y enregistrerait.",
],
[
'PLATEFORME_WEB',
() =>
"Sous la plateforme web, ni le renommage atomique ni le verrou n'existent : " +
'une panne peut laisser un fichier incomplet, et deux onglets peuvent écrire le même événement.',
],
// dossier, quand il est donné, nomme le dossier où la séance écrit
// désormais (ecrireAilleurs) ; sans lui, le dossier de travail.
[
'SUPPORT_AMOVIBLE',
({ dossier }) =>
`${typeof dossier === 'string' ? `Le dossier ${cite(dossier)}` : 'Le dossier de travail'} ` +
'est sur un support amovible : retirer le support pendant une écriture peut la laisser incomplète.',
],
[
'REGLAGES_ILLISIBLES',
({ chemin, raison }) => {
const pourquoi =
raison === 'JSON' || raison === 'FORME' ? texteDe(raison) : `La lecture est refusée (${raison}).`;
return `Les réglages locaux ${cite(chemin)} ne se lisent pas. ${pourquoi} Les valeurs par défaut s'appliquent.`;
},
],
// --- Le CSV (§ 10.1) -------------------------------------------------------
['UTF16_SANS_MARQUE', refusEncodage("Le fichier semble encodé en UTF-16, sans la marque qui l'annonce.")],
['UTF16_INVALIDE', refusEncodage("Le fichier s'annonce en UTF-16, mais ses octets ne le sont pas.")],
['UTF8_INVALIDE', refusEncodage("Le fichier s'annonce en UTF-8, mais ses octets ne le sont pas.")],
['CARACTERE_NUL', refusEncodage("Le texte porte un caractère nul, qu'aucune liste ne contient.")],
['ENREGISTRER_EN_UTF8', () => REMEDE_UTF8],
[
'SEPARATEUR_INTROUVABLE',
({ ligne, cause }) => {
const constat = 'Aucun séparateur ne convient : ni le point-virgule, ni la virgule, ni la tabulation.';
if (ligne === null || ligne === undefined) return constat;
const ecart = texteDe(cause === 'GUILLEMET_OUVERT' ? 'GUILLEMET_OUVERT' : 'NOMBRE_DE_CHAMPS', { ligne });
return `${constat} ${ecart}`;
},
],
['NOMBRE_DE_CHAMPS', ({ ligne }) => `Avec chacun, la ligne ${ligne} n'a pas le nombre de champs de l'en-tête.`],
[
'GUILLEMET_OUVERT',
({ ligne }) =>
`Un guillemet ouvert${Number.isInteger(ligne) ? ` à la ligne ${ligne}` : ''} ne se referme pas : ` +
'tout ce qui le suit tiendrait dans un seul champ.',
],
['AUCUN_ENTETE', () => "Aucun en-tête n'est reconnu : la première ligne nomme les colonnes, « nom » au moins."],
['NOM_NON_ASSOCIE', () => "Aucune colonne ne porte le nom : l'en-tête doit compter une colonne « nom »."],
['AUCUNE_LIGNE_VALIDE', () => "Aucune ligne n'est valide : rien n'est importé."],
['CHAMPS_EN_TROP', () => "La ligne compte plus de champs que l'en-tête."],
['NOM_ABSENT', () => 'Le nom manque.'],
[
'EXCLU_INCONNU',
({ valeur }) =>
`La valeur d'« exclu » ${cite(valeur)} n'est pas reconnue : ` +
'oui, o, vrai, 1 ou x pour exclure ; non, n, faux, 0 ou vide sinon.',
],
[
'PLUSIEURS_CORRESPONDENT',
({ participants }) =>
'Plusieurs personnes de la liste correspondent à cette ligne ' +
`(${enumeration(participants.map((id) => `n° ${id}`))}) : rien n'est fusionné.`,
],
[
'DEJA_DESIGNE',
({ premiereLigne }) => `La ligne ${premiereLigne} désigne déjà cette personne : rien n'est fusionné.`,
],
['PLAN_BLOQUE', () => 'Le plan est bloqué : « Débloquer » le rend de nouveau modifiable.'],
[
'AMBIGUITE',
({ champs }) =>
`Plusieurs colonnes répondent au même champ (${enumeration(champs)}) : choisissez celle qui s'importe.`,
],
// --- La séance (§ 8.3, § 8.4, § 8.5, § 8.6, § 8.8) -------------------------
['AUCUN_EVENEMENT', () => "Aucun événement n'est ouvert."],
['MODE_LECTURE', () => "L'événement est ouvert en lecture : « Modifier » permet de le changer."],
['FORMAT', (details) => texteDe('FORMAT_PLUS_RECENT', details)],
['BLOQUE', () => 'Le plan est bloqué.'],
// echec nomme ce que le support n'a pas reçu : la ligne d'un geste
// (ECRITURE), l'état d'un geste inscrit au journal (ETAT_NON_ECRIT), le
// jalon d'un geste enregistré (JALON_NON_ECRIT), ou le verrou d'un passage
// en écriture (VERROU_NON_ECRIT), qui ne perd rien.
[
'SUPPORT_ROMPU',
({ echec, ...details }) => {
const refus = `Le support ne prend plus les écritures : ${ouEcrire(details)} n'a pas pu s'écrire`;
const ailleurs = "« Écrire ailleurs » enregistre l'événement dans un autre dossier.";
if (echec === 'ECRITURE' || echec === 'ETAT_NON_ECRIT') {
const perte =
echec === 'ETAT_NON_ECRIT'
? "le dernier geste est inscrit à l'historique, mais pas dans l'état"
: "le dernier geste n'est pas enregistré";
return `${refus}, et ${perte}. Sur ce support, l'historique et l'état ne se correspondent plus ; ${ailleurs}`;
}
if (echec === 'JALON_NON_ECRIT') {
return (
`${refus}. Le dernier geste est enregistré, mais pas le jalon qui le nomme, ` +
`et aucun geste ne s'y enregistrerait plus ; ${ailleurs}`
);
}
if (echec === 'VERROU_NON_ECRIT') {
return (
`${refus}. L'événement y reste tel qu'il s'affiche, ` +
`mais aucun geste ne s'y enregistrerait ; ${ailleurs}`
);
}
throw new RangeError(`libelle : échec du support inconnu ${JSON.stringify(echec)}`);
},
],
['SUPPORT_INTACT', () => "Le support prend encore les écritures : l'événement reste où il est."],
// Quitter l'événement d'un support rompu : la séance tient la seule copie
// que l'écriture ailleurs peut encore sauver ; ETAT_NON_ECRIT dit que son
// dernier geste n'est qu'à l'historique de ce support.
[
'SUPPORT_ROMPU_NON_SAUVE',
({ echec, dossier }) => {
const geste =
echec === 'ETAT_NON_ECRIT' ? ", et son dernier geste n'y est inscrit qu'à l'historique" : '';
return (
`Le support ${cite(dossier)} ne prend plus les écritures${geste} : ` +
"l'événement ouvert n'est sauvé dans aucun autre dossier. " +
"« Écrire ailleurs » l'enregistre d'abord ; le quitter maintenant l'abandonne à ce support."
);
},
],
[
'EVENEMENT_CHANGE',
({ revision }) =>
`Une autre séance a modifié cet événement depuis son ouverture : il s'affiche tel qu'elle l'a laissé, ` +
`à la révision ${revision}, et les gestes suivants s'y ajoutent.`,
],
[
'VERROU_REPRIS',
({ depuis }) => `Le verrou qu'une séance interrompue de ce poste avait laissé${depuisQuand(depuis)} est repris.`,
],
[
'VERROU_NON_RENDU',
({ cause }) =>
`Le verrou de l'événement n'a pas pu être rendu${causeEntreParentheses(cause)} : ` +
'une autre séance de ce poste le reprendra une fois ce logiciel fermé.',
],
['RIEN_A_DEFAIRE', () => "Rien à défaire : l'historique commence à cet instant."],
['RIEN_A_REFAIRE', () => 'Rien à refaire : seul un retour se refait, tant que nul geste ne le suit.'],
['INSTANT_NON_RESTITUABLE', ({ revision }) => `L'historique ne restitue plus l'instant ${revision}.`],
['PLAN_NON_BLOQUE', () => "Le plan n'est pas bloqué : il n'y a rien à débloquer."],
[
'JALON_NON_ECRIT',
({ dossier, cause }) =>
`Le geste est enregistré, mais le jalon qui le nomme n'a pas pu s'écrire ` +
`dans le dossier ${cite(dossier)}${causeEntreParentheses(cause)}.`,
],
['SANS_EFFET', () => "Ce geste ne change rien : rien n'est enregistré."],
// --- Les refus et les avertissements des commandes (§ 4.4, § 9, § 10.2) ---
['NOM_REQUIS', () => 'Le nom est obligatoire.'],
['DATE_INVALIDE', ({ date }) => `La date ${cite(String(date))} n'est pas un jour du calendrier, écrit AAAA-MM-JJ.`],
['SIEGES_INVALIDES', () => 'Une table compte au moins deux sièges.'],
['TOURS_INVALIDES', () => 'La soirée compte au moins un tour.'],
['PARTICIPANT_INCONNU', ({ id }) => `La personne n° ${id} n'est pas dans cet événement.`],
['PROPOSITION_INCONNUE', ({ id }) => `La proposition n° ${id} n'est pas dans cet événement.`],
['AUCUNE_PROPOSITION', () => "Aucune proposition n'existe : l'état « Proposé » suit une génération."],
['AUCUN_RETENU', () => "Aucun placement n'est retenu : l'état « Retenu » suit le choix d'une proposition."],
[
'RESERVATIONS_SUSPENDUES',
({ personne, reservations, titres }) =>
`Exclusion de ${personne} : ${compte(reservations, 'réservation suspendue', 'réservations suspendues')}, ` +
`ses places reviennent aux autres${titresDe(titres, "n'est plus pourvu", 'ne sont plus pourvus')}.`,
],
[
'RESERVATIONS_RETABLIES',
({ personne, reservations, titres }) =>
`Réintégration de ${personne} : ${compte(reservations, 'réservation rétablie', 'réservations rétablies')}` +
`${titresDe(titres, 'de nouveau pourvu', 'de nouveau pourvus')}.`,
],
[
'RESERVATIONS_RETIREES',
({ personne, reservations, titres }) =>
`Suppression de ${personne} : ${compte(reservations, 'réservation retirée', 'réservations retirées')}` +
`${titresDe(titres, "n'est plus pourvu", 'ne sont plus pourvus')}.`,
],
[
'IMPORT_EN_RETENU',
() =>
"L'import change la liste après le choix d'un placement : le placement retenu est gardé tel quel, " +
"et rien n'est effacé ; à vous de juger s'il convient encore.",
],
// --- Les libellés figés du journal (§ 8.2, § 8.3) --------------------------
['ENTREE_CREATION', ({ nom }) => `Création de l'événement ${cite(nom)}`],
['JALON_CREATION', () => 'Création'],
['JALON_ETAT', ({ etat }) => `État ${cite(nomEtat(etat))}`],
['JALON_AVANT_GENERATION', () => 'Avant la génération'],
['ENTREE_REPRISE', () => "Reprise de l'état trouvé sur le disque"],
['ENTREE_DEFAIRE', ({ libelle: instant }) => `Défait : ${cite(instant)}`],
['ENTREE_REFAIRE', ({ libelle: instant }) => `Refait : ${cite(instant)}`],
['ENTREE_REVENIR', ({ libelle: instant }) => `Retour à ${cite(instant)}`],
['ENTREE_AJOUTER_PARTICIPANT', ({ personne }) => `Ajouté ${personne}`],
[
'ENTREE_MODIFIER_PARTICIPANT',
({ personne, champs, devenu }) => {
const nouveau = devenu === null || devenu === undefined ? '' : ` (désormais ${devenu})`;
return `Modifié ${personne} : ${champs.map(nomChamp).join(', ')}${nouveau}`;
},
],
['ENTREE_EXCLURE_PARTICIPANT', ({ personne }) => `Exclu ${personne}`],
['ENTREE_REINTEGRER_PARTICIPANT', ({ personne }) => `Réintégré ${personne}`],
['ENTREE_SUPPRIMER_PARTICIPANT', ({ personne }) => `Supprimé ${personne}`],
[
'ENTREE_IMPORTER_PARTICIPANTS',
({ mode, ajoutes, misAJour, retires }) => {
const ajoutesTexte = compte(ajoutes, 'ajouté', 'ajoutés');
if (mode === 'ajouter') return `Importé ${compte(ajoutes, 'participant', 'participants')}`;
if (mode === 'mettreAJour') {
return `Mis à jour la liste par import : ${ajoutesTexte}, ${compte(misAJour, 'modifié', 'modifiés')}`;
}
if (mode === 'remplacer') {
return `Remplacé la liste par import : ${compte(retires, 'retiré', 'retirés')}, ${ajoutesTexte}`;
}
throw new RangeError(`libelle : mode d'import inconnu ${JSON.stringify(mode)}`);
},
],
['ENTREE_CHANGER_ETAT', ({ etat }) => `Plan passé à l'état ${cite(nomEtat(etat))}`],
['ENTREE_DEBLOQUER', ({ etat }) => `Plan débloqué, état ${cite(nomEtat(etat))}`],
[
'ENTREE_ENREGISTRER_GENERATION',
({ n, premier, dernier }) =>
`Généré ${compte(n, 'proposition', 'propositions')} ` +
`(n° ${premier === dernier ? premier : `${premier} à ${dernier}`})`,
],
['ENTREE_RETENIR_PROPOSITION', ({ id }) => `Retenu la proposition n° ${id}`],
[
'ENTREE_EFFACER_PROPOSITIONS',
({ n, gardee }) =>
`Effacé ${compte(n, 'proposition', 'propositions')}` +
`${gardee === null || gardee === undefined ? '' : `, gardé la retenue (n° ${gardee})`}`,
],
]);
// Le texte d'un code de la table, ou RangeError qui le nomme.
function texteDe(code, details = {}) {
const texte = TEXTES.get(code);
if (texte === undefined) throw new RangeError(`libelle : code inconnu ${JSON.stringify(code)}`);
return texte(details ?? {});
}
/** Les codes qui ont un texte, triés par unités UTF-16 croissantes. */
export const CODES = Object.freeze([...TEXTES.keys()].sort(comparerTextes));
/** /**
* Le texte français d'un code et de ses détails (§ 14.6) : un refus, un * Le texte français d'une clé et de ses détails (§ 14.6) : un refus, un
* avertissement, une raison, ou le libellé figé d'une entrée ou d'un jalon. * avertissement, une raison, le libellé figé d'une entrée ou d'un jalon, ou
* un libellé de l'application.
* *
* @param {string} code * @param {string} cle
* @param {Object} [details] * @param {Object} [details]
* @returns {string} * @returns {string}
* @throws {RangeError} un code sans texte, ou une valeur codée de ses détails * @throws {RangeError} une clé sans texte, ou une valeur codée de ses détails
* — état, champ, mode, échec — que la table ne connaît pas ; jamais * — état, champ, mode, échec — que la table ne connaît pas ; jamais
* une raison, qu'une phrase générique nomme * une raison, qu'une phrase générique nomme
*/ */
export function libelle(code, details = {}) { export function libelle(cle, details = {}) {
return texteDe(code, details); return FRANCAIS.t(cle, details);
} }

View file

@ -12,20 +12,27 @@
// porte une. Chaque code relevé a son texte, chaque texte a un émetteur, et // porte une. Chaque code relevé a son texte, chaque texte a un émetteur, et
// un balayage vide échoue. Chaque texte se compose ensuite de détails // un balayage vide échoue. Chaque texte se compose ensuite de détails
// d'exemple. Les noms d'épreuve sont inventés. // d'exemple. Les noms d'épreuve sont inventés.
//
// La table se partage en fichiers, un par domaine, sous libelles/ : chacun
// exporte TABLE, une Map, et libelles.js les assemble par ordre alphabétique
// des fichiers ; les tables, comme l'assemblage, sont hors du balayage.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs'; import { readdirSync, readFileSync } from 'node:fs';
import { join, relative } from 'node:path'; import { join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { describe, test } from '../../test/lanceur.js'; import { describe, test } from '../../test/lanceur.js';
import { CODES, libelle } from './libelles.js'; import { CODES, TABLES_APPLICATION, libelle } from './libelles.js';
const RACINE = fileURLToPath(new URL('../..', import.meta.url)); const RACINE = fileURLToPath(new URL('../..', import.meta.url));
// Les arbres balayés, et les fichiers qui portent une table de codes. // Les arbres balayés, et les fichiers qui portent une table de codes.
const ARBRES = ['src/stockage', 'src/csv', 'src/application']; const ARBRES = ['src/stockage', 'src/csv', 'src/application'];
const TABLES = ['src/stockage/types.js', 'src/csv/erreurs.js']; const TABLES = ['src/stockage/types.js', 'src/csv/erreurs.js'];
// La table des textes elle-même n'émet rien : ses clés sont ce qu'on éprouve. // L'assemblage et les tables des textes n'émettent rien : leurs clés sont ce
// qu'on éprouve, et un texte qui en compose un autre le nomme sans l'émettre.
const TABLE_DES_TEXTES = join('src', 'application', 'libelles.js'); const TABLE_DES_TEXTES = join('src', 'application', 'libelles.js');
const DOSSIER_DES_TABLES = join('src', 'application', 'libelles');
const horsDesTextes = (chemin) => chemin !== TABLE_DES_TEXTES && !chemin.startsWith(DOSSIER_DES_TABLES + sep);
// --- Le balayage ---------------------------------------------------------- // --- Le balayage ----------------------------------------------------------
@ -79,7 +86,7 @@ function codesDesTables(texte) {
function modulesDe(arbre) { function modulesDe(arbre) {
return readdirSync(join(RACINE, arbre), { recursive: true }) return readdirSync(join(RACINE, arbre), { recursive: true })
.map((nom) => join(arbre, nom)) .map((nom) => join(arbre, nom))
.filter((chemin) => chemin.endsWith('.js') && !/\.test\.js$/.test(chemin) && chemin !== TABLE_DES_TEXTES) .filter((chemin) => chemin.endsWith('.js') && !/\.test\.js$/.test(chemin) && horsDesTextes(chemin))
.sort(); .sort();
} }
@ -158,6 +165,16 @@ describe('libellés : le balayage des codes (§ 14.6, § 14.2)', () => {
} }
}); });
test("le balayage exclut l'assemblage et chaque table de libelles/, et garde les autres modules de l'application", () => {
const tables = readdirSync(join(RACINE, DOSSIER_DES_TABLES)).filter((nom) => nom.endsWith('.js'));
assert.ok(tables.length >= 10, `${DOSSIER_DES_TABLES} : ${tables.length} modules`);
const balayes = modulesDe('src/application');
assert.ok(balayes.length > 0, 'src/application : aucun module balayé');
const dossier = join('src', 'application', 'libelles');
assert.deepEqual(balayes.filter((chemin) => chemin === `${dossier}.js` || chemin.startsWith(dossier + sep)), []);
for (const garde of ['commandes.js', 'seance.js']) assert.ok(balayes.includes(join('src', 'application', garde)), garde);
});
test('chaque arbre émet des codes, et la table de contrat du stockage en porte', () => { test('chaque arbre émet des codes, et la table de contrat du stockage en porte', () => {
const { parArbre, tables } = releverArbre(); const { parArbre, tables } = releverArbre();
for (const arbre of ARBRES) assert.ok(parArbre.get(arbre).size > 0, `${arbre} : balayage vide`); for (const arbre of ARBRES) assert.ok(parArbre.get(arbre).size > 0, `${arbre} : balayage vide`);
@ -249,6 +266,7 @@ const EXEMPLES = new Map([
['DONNEES_APPLICATIVES', {}], ['DONNEES_APPLICATIVES', {}],
['SONDE_ECHOUEE', { cause: 'EROFS' }], ['SONDE_ECHOUEE', { cause: 'EROFS' }],
['RELECTURE_DIFFERENTE', {}], ['RELECTURE_DIFFERENTE', {}],
['NOM_NON_CONVERTI', {}],
['PORTABLE_NON_INSCRIPTIBLE', { racine: { id: 'portable', chemin: 'E:\\data' }, evenements: 3, cause: 'EROFS' }], ['PORTABLE_NON_INSCRIPTIBLE', { racine: { id: 'portable', chemin: 'E:\\data' }, evenements: 3, cause: 'EROFS' }],
['DOCUMENTS_NON_INSCRIPTIBLE', { racine: { id: 'documents', chemin: 'C:\\Documents\\Table' }, evenements: 0, cause: 'EACCES' }], ['DOCUMENTS_NON_INSCRIPTIBLE', { racine: { id: 'documents', chemin: 'C:\\Documents\\Table' }, evenements: 0, cause: 'EACCES' }],
['PLATEFORME_WEB', {}], ['PLATEFORME_WEB', {}],
@ -525,3 +543,70 @@ describe('libellés : les textes (§ 14.6, § 8.2)', () => {
assert.match(libelle('PROPOSITIONS_FAUTIVES', { ids: [3, null] }), /n° 3/); assert.match(libelle('PROPOSITIONS_FAUTIVES', { ids: [3, null] }), /n° 3/);
}); });
}); });
// --- Les tables par domaine ---------------------------------------------------
// Les deux formes de clé du contrat : un code, nu ; un libellé, préfixé du nom
// de sa table, en camelCase.
const FORME_CODE = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/;
const formeLibelle = (table) => new RegExp(`^${table}(?:\\.[a-z][A-Za-z0-9]*)+$`);
// Les modules de table du dossier, aides et épreuves exceptées, par ordre
// alphabétique, et ce que chacun exporte.
async function tablesDuDossier() {
const noms = readdirSync(join(RACINE, DOSSIER_DES_TABLES))
.filter((nom) => nom.endsWith('.js') && !nom.endsWith('.test.js') && nom !== 'aides.js')
.sort();
return Promise.all(noms.map(async (nom) => ({ table: nom.slice(0, -3), module: await import(`./libelles/${nom}`) })));
}
describe('libellés : les tables par domaine (§ 14.6)', () => {
test('chaque module de libelles/, aides.js excepté, exporte TABLE, une Map ; TABLES_APPLICATION les assemble, dix, par ordre alphabétique', async () => {
const tables = await tablesDuDossier();
assert.equal(tables.length, 10);
assert.deepEqual(tables.map(({ table }) => table), [
'csv', 'evenements', 'format', 'generation', 'mobilier', 'moteur', 'participants', 'places', 'seance', 'stockage',
]);
for (const { table, module } of tables) assert.ok(module.TABLE instanceof Map, table);
assert.equal(TABLES_APPLICATION.length, tables.length);
tables.forEach(({ table, module }, rang) => assert.equal(TABLES_APPLICATION[rang], module.TABLE, table));
});
test("chaque clé a l'une des deux formes ; un libellé commence par le nom de sa table ; participants et generation ne portent que des codes", async () => {
const tables = await tablesDuDossier();
let comptees = 0;
for (const { table, module } of tables) {
for (const cle of module.TABLE.keys()) {
comptees += 1;
const code = FORME_CODE.test(cle);
assert.ok(code || formeLibelle(table).test(cle), `${table} : clé ${JSON.stringify(cle)}`);
if (table === 'participants' || table === 'generation') assert.ok(code, `${table} : ${cle} n'est pas un code`);
}
}
assert.ok(comptees >= CODES.length, `${comptees} clés`);
});
test("chaque texte d'A1 est dans la table du domaine qui l'émet", async () => {
const parTable = new Map((await tablesDuDossier()).map(({ table, module }) => [table, module.TABLE]));
const ATTENDUS = [
['stockage', ['ABSENT', 'VERROU_PRIS', 'LECTURE_SEULE', 'DERIVE', 'RELECTURE_DIFFERENTE', 'COMPTEUR_SATURE']],
['csv', ['NOM_ABSENT', 'UTF8_INVALIDE', 'ENREGISTRER_EN_UTF8', 'PLAN_BLOQUE', 'AMBIGUITE']],
['seance', ['MODE_LECTURE', 'SUPPORT_ROMPU', 'SANS_EFFET', 'ENTREE_DEFAIRE', 'JALON_CREATION']],
['participants', ['NOM_REQUIS', 'PARTICIPANT_INCONNU', 'RESERVATIONS_SUSPENDUES', 'IMPORT_EN_RETENU', 'ENTREE_AJOUTER_PARTICIPANT']],
['evenements', ['ENTREE_CREATION', 'DATE_INVALIDE', 'AUCUN_RETENU', 'PLAN_NON_BLOQUE', 'ENTREE_DEBLOQUER']],
['generation', ['PROPOSITION_INCONNUE', 'ENTREE_ENREGISTRER_GENERATION', 'ENTREE_EFFACER_PROPOSITIONS']],
['format', ['format.vide', 'format.duree.vingtMinutes']],
];
assert.equal(ATTENDUS.length, 7);
for (const [table, cles] of ATTENDUS) for (const cle of cles) assert.ok(parTable.get(table).has(cle), `${cle} hors de ${table}.js`);
});
test("CODES ne compte que les clés de forme code ; libelle lit aussi un libellé de l'application", () => {
assert.ok(CODES.length > 0);
assert.deepEqual(CODES.filter((cle) => !FORME_CODE.test(cle)), []);
assert.ok(!CODES.includes('format.vide'));
assert.equal(libelle('format.vide'), '\u{2014}');
const toutes = TABLES_APPLICATION.flatMap((table) => [...table.keys()]).filter((cle) => FORME_CODE.test(cle));
assert.deepEqual([...CODES], [...toutes].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)));
});
});

View file

@ -0,0 +1,102 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les aides de composition que partagent les tables de l'application : citer,
// compter, énumérer, dater, nommer un état. Ce module n'est pas une table et
// n'exporte aucune TABLE. Une aide qui lit le texte d'une autre clé reçoit
// texte, la fonction de composition que la traduction passe à chaque entrée
// (traduction.js) : elle ne connaît pas l'assemblage des tables.
//
// Les aides ne lisent ni l'horloge ni la langue du système : un horodatage se
// met en mots à partir de sa seule forme, AAAA-MM-JJTHH:MM:SS±HH:MM.
const NOMS_DES_ETATS = new Map([
['brouillon', 'Brouillon'],
['propose', 'Proposé'],
['retenu', 'Retenu'],
['bloque', 'Bloqué'],
]);
/** Valeur d'une table de noms, ou RangeError qui la nomme. */
export function nomDans(table, valeur, quoi) {
if (!table.has(valeur)) throw new RangeError(`libelle : ${quoi} inconnu ${JSON.stringify(valeur)}`);
return table.get(valeur);
}
/** Le nom d'un état du plan (§ 9) ; un état inconnu lève RangeError. */
export const nomEtat = (etat) => nomDans(NOMS_DES_ETATS, etat, 'état');
/** Un texte cité entre guillemets français. */
export const cite = (texte) => `« ${texte} »`;
/** Un compte et son nom, au singulier jusqu'à un, comme le veut le français. */
export const compte = (n, singulier, pluriel) => `${n} ${n <= 1 ? singulier : pluriel}`;
/** Une énumération : « a », « a et b », « a, b et c ». */
export function enumeration(termes) {
if (termes.length <= 1) return termes.join('');
return `${termes.slice(0, -1).join(', ')} et ${termes.at(-1)}`;
}
/** La première lettre en capitale. */
export const capitale = (texte) => texte.charAt(0).toUpperCase() + texte.slice(1);
/** Le chemin de l'élément fautif, rien quand il manque ou désigne la racine. */
export const element = (chemin) => (typeof chemin === 'string' && chemin !== '' ? ` (${chemin})` : '');
/** Le chemin affichable d'une racine, reçue entière ou par son seul chemin. */
export const cheminDe = (racine) => (typeof racine === 'string' ? racine : racine?.chemin);
/**
* Une cause entre parenthèses, rien quand elle manque : son texte quand les
* tables en portent un, sinon la cause telle que le système la nomme.
*/
export function causeEntreParentheses(cause, texte) {
if (cause === null || cause === undefined || cause === '') return '';
return ` (${texte.existe(cause) ? texte(cause) : cause})`;
}
/** Ce qu'un refus d'écriture nomme : le fichier, son dossier, la cause. */
export const ouEcrire = ({ chemin, dossier, cause }, texte) =>
`${cite(chemin)} dans le dossier ${cite(dossier)}${causeEntreParentheses(cause, texte)}`;
/**
* Une raison codée en une phrase : son texte quand les tables en portent un
* et que les détails reçus lui suffisent ; sinon une phrase générique qui la
* cite telle quelle. Un texte qui attend ses propres détails lève ou rend
* « undefined » sans eux : la phrase générique le remplace alors.
*/
export function phraseDeRaison(raison, details, texte) {
const generique = `La raison donnée est ${cite(String(raison))}.`;
if (!texte.existe(raison)) return generique;
try {
const rendu = texte(raison, details);
return typeof rendu === 'string' && !rendu.includes('undefined') ? rendu : generique;
} catch {
return generique;
}
}
/** Des numéros de proposition, un illisible nommé comme tel. */
export const numeros = (ids) => enumeration(ids.map((id) => (id === null ? 'sans numéro lisible' : `n° ${id}`)));
// La forme d'un horodatage : date, heure et minute se lisent, les secondes et
// le décalage se taisent.
const HORODATAGE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):\d{2}[+-]\d{2}:\d{2}$/;
/**
* Un horodatage AAAA-MM-JJTHH:MM:SS±HH:MM en mots, « le 17 mai 2031 à
* 8 h 05 », à l'heure qu'il porte, par les textes de la mise en forme
* (format.date, format.heure) ; une autre forme se cite telle quelle.
*/
export function dater(horodatage, texte) {
const morceaux = HORODATAGE.exec(String(horodatage));
if (morceaux === null) return String(horodatage);
const [annee, mois, jour, heure, minute] = morceaux.slice(1).map(Number);
if (mois < 1 || mois > 12) return String(horodatage);
return `le ${texte('format.date', { annee, mois, jour })} à ${texte('format.heure', { heure, minute })}`;
}
/** « depuis » et l'horodatage en mots, rien quand il manque. */
export const depuisQuand = (depuis, texte) =>
depuis === null || depuis === undefined ? '' : ` depuis ${dater(depuis, texte)}`;

View file

@ -0,0 +1,62 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les textes des codes de src/csv (§ 10.1, § 14.6) : encodage, séparateur,
// en-tête, refus d'une ligne, refus de la mise à jour.
import { cite, enumeration } from './aides.js';
// Le remède commun aux refus d'encodage (§ 10.1).
const REMEDE_UTF8 = 'Réenregistrez le fichier en UTF-8 depuis le tableur, puis importez-le de nouveau.';
const refusEncodage = (constat) => () => `${constat} ${REMEDE_UTF8}`;
export const TABLE = new Map([
// --- Le CSV (§ 10.1) -------------------------------------------------------
['UTF16_SANS_MARQUE', refusEncodage("Le fichier semble encodé en UTF-16, sans la marque qui l'annonce.")],
['UTF16_INVALIDE', refusEncodage("Le fichier s'annonce en UTF-16, mais ses octets ne le sont pas.")],
['UTF8_INVALIDE', refusEncodage("Le fichier s'annonce en UTF-8, mais ses octets ne le sont pas.")],
['CARACTERE_NUL', refusEncodage("Le texte porte un caractère nul, qu'aucune liste ne contient.")],
['ENREGISTRER_EN_UTF8', () => REMEDE_UTF8],
[
'SEPARATEUR_INTROUVABLE',
({ ligne, cause }, texte) => {
const constat = 'Aucun séparateur ne convient : ni le point-virgule, ni la virgule, ni la tabulation.';
if (ligne === null || ligne === undefined) return constat;
const ecart = texte(cause === 'GUILLEMET_OUVERT' ? 'GUILLEMET_OUVERT' : 'NOMBRE_DE_CHAMPS', { ligne });
return `${constat} ${ecart}`;
},
],
['NOMBRE_DE_CHAMPS', ({ ligne }) => `Avec chacun, la ligne ${ligne} n'a pas le nombre de champs de l'en-tête.`],
[
'GUILLEMET_OUVERT',
({ ligne }) =>
`Un guillemet ouvert${Number.isInteger(ligne) ? ` à la ligne ${ligne}` : ''} ne se referme pas : ` +
'tout ce qui le suit tiendrait dans un seul champ.',
],
['AUCUN_ENTETE', () => "Aucun en-tête n'est reconnu : la première ligne nomme les colonnes, « nom » au moins."],
['NOM_NON_ASSOCIE', () => "Aucune colonne ne porte le nom : l'en-tête doit compter une colonne « nom »."],
['AUCUNE_LIGNE_VALIDE', () => "Aucune ligne n'est valide : rien n'est importé."],
['CHAMPS_EN_TROP', () => "La ligne compte plus de champs que l'en-tête."],
['NOM_ABSENT', () => 'Le nom manque.'],
[
'EXCLU_INCONNU',
({ valeur }) =>
`La valeur d'« exclu » ${cite(valeur)} n'est pas reconnue : ` +
'oui, o, vrai, 1 ou x pour exclure ; non, n, faux, 0 ou vide sinon.',
],
[
'PLUSIEURS_CORRESPONDENT',
({ participants }) =>
'Plusieurs personnes de la liste correspondent à cette ligne ' +
`(${enumeration(participants.map((id) => `n° ${id}`))}) : rien n'est fusionné.`,
],
[
'DEJA_DESIGNE',
({ premiereLigne }) => `La ligne ${premiereLigne} désigne déjà cette personne : rien n'est fusionné.`,
],
['PLAN_BLOQUE', () => 'Le plan est bloqué : « Débloquer » le rend de nouveau modifiable.'],
[
'AMBIGUITE',
({ champs }) =>
`Plusieurs colonnes répondent au même champ (${enumeration(champs)}) : choisissez celle qui s'importe.`,
],
]);

View file

@ -0,0 +1,22 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les textes de la création d'un événement, de ses états et du déblocage
// (§ 8.1, § 9, § 14.6) : refus et libellés figés. Un état inconnu lève
// RangeError, au lieu de se taire.
import { cite, nomEtat } from './aides.js';
export const TABLE = new Map([
// --- Les refus de la création, des états et du déblocage (§ 8.1, § 9) -----
['PLAN_NON_BLOQUE', () => "Le plan n'est pas bloqué : il n'y a rien à débloquer."],
['DATE_INVALIDE', ({ date }) => `La date ${cite(String(date))} n'est pas un jour du calendrier, écrit AAAA-MM-JJ.`],
['SIEGES_INVALIDES', () => 'Une table compte au moins deux sièges.'],
['TOURS_INVALIDES', () => 'La soirée compte au moins un tour.'],
['AUCUNE_PROPOSITION', () => "Aucune proposition n'existe : l'état « Proposé » suit une génération."],
['AUCUN_RETENU', () => "Aucun placement n'est retenu : l'état « Retenu » suit le choix d'une proposition."],
// --- Les libellés figés du journal (§ 8.2, § 8.3) --------------------------
['ENTREE_CREATION', ({ nom }) => `Création de l'événement ${cite(nom)}`],
['ENTREE_CHANGER_ETAT', ({ etat }) => `Plan passé à l'état ${cite(nomEtat(etat))}`],
['ENTREE_DEBLOQUER', ({ etat }) => `Plan débloqué, état ${cite(nomEtat(etat))}`],
]);

View file

@ -0,0 +1,60 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les textes de la mise en forme (format.js, § 5.4, § 5.10, § 18.4) : la
// valeur absente, les unités d'une durée, son ordre de grandeur, les mois et
// l'heure. Les entrées reçoivent des nombres déjà contrôlés par creerFormat,
// ou des chiffres déjà mis en forme ; un mois hors de 1 à 12 lève RangeError.
const MOIS = [
'janvier',
'février',
'mars',
'avril',
'mai',
'juin',
'juillet',
'août',
'septembre',
'octobre',
'novembre',
'décembre',
];
// Le nom d'un mois, de 1 à 12.
function nomDuMois(mois) {
if (!Number.isInteger(mois) || mois < 1 || mois > 12) {
throw new RangeError(`format : mois inconnu ${JSON.stringify(mois)}`);
}
return MOIS[mois - 1];
}
export const TABLE = new Map([
// Une valeur absente, celle d'une population vide (§ 5.4).
['format.vide', '\u{2014}'],
// Une durée : secondes, déjà écrites ; minutes et secondes ; heures et
// minutes. Une part nulle se tait.
['format.duree.secondes', ({ secondes }) => `${secondes} s`],
[
'format.duree.minutes',
({ minutes, secondes }) => (secondes === 0 ? `${minutes} min` : `${minutes} min ${secondes} s`),
],
['format.duree.heures', ({ heures, minutes }) => (minutes === 0 ? `${heures} h` : `${heures} h ${minutes} min`)],
// L'ordre de grandeur d'une durée (§ 5.10), du plus court au plus long.
['format.duree.moinsDUneMinute', "moins d'une minute"],
['format.duree.deuxMinutes', 'environ deux minutes'],
['format.duree.cinqMinutes', 'environ cinq minutes'],
['format.duree.dixMinutes', 'environ dix minutes'],
['format.duree.vingtMinutes', 'environ vingt minutes'],
['format.duree.demiHeure', 'environ une demi-heure'],
['format.duree.uneHeure', 'environ une heure'],
['format.duree.plusieursHeures', 'plusieurs heures'],
// Une date en lettres, « 1er » pour le premier du mois (§ 18.4) ; l'heure
// sans zéro de tête, la minute sur deux chiffres.
['format.date', ({ annee, mois, jour }) => `${jour === 1 ? '1er' : jour} ${nomDuMois(mois)} ${annee}`],
['format.heure', ({ heure, minute }) => `${heure} h ${String(minute).padStart(2, '0')}`],
['format.horodatage', (details, texte) => `${texte('format.date', details)}, ${texte('format.heure', details)}`],
]);

View file

@ -0,0 +1,26 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les textes des commandes de la génération et des propositions (§ 5.7,
// § 14.6) : refus et libellés figés. Cette table ne porte que des codes.
import { compte } from './aides.js';
export const TABLE = new Map([
// --- Les refus des propositions (§ 5.7) ------------------------------------
['PROPOSITION_INCONNUE', ({ id }) => `La proposition n° ${id} n'est pas dans cet événement.`],
// --- Les libellés figés du journal (§ 8.2, § 8.3) --------------------------
[
'ENTREE_ENREGISTRER_GENERATION',
({ n, premier, dernier }) =>
`Généré ${compte(n, 'proposition', 'propositions')} ` +
`(n° ${premier === dernier ? premier : `${premier} à ${dernier}`})`,
],
['ENTREE_RETENIR_PROPOSITION', ({ id }) => `Retenu la proposition n° ${id}`],
[
'ENTREE_EFFACER_PROPOSITIONS',
({ n, gardee }) =>
`Effacé ${compte(n, 'proposition', 'propositions')}` +
`${gardee === null || gardee === undefined ? '' : `, gardé la retenue (n° ${gardee})`}`,
],
]);

View file

@ -0,0 +1,5 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les textes des commandes du mobilier — tables, sièges, tours (§ 6, § 14.6).
export const TABLE = new Map();

View file

@ -0,0 +1,6 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les textes des codes du moteur qui n'ont pas leur table ailleurs, et les
// libellés de ses critères (§ 5, § 14.6).
export const TABLE = new Map();

View file

@ -0,0 +1,80 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les textes des commandes de la liste des participants et de l'import
// (§ 4.4, § 10.2, § 14.6) : leurs refus, leurs avertissements et leurs
// libellés figés. Cette table ne porte que des codes. Un champ inconnu lève
// RangeError, au lieu de se taire.
import { compte, nomDans } from './aides.js';
// Les champs d'un participant qu'une saisie modifie, dans l'ordre du fichier.
const NOMS_DES_CHAMPS = new Map([
['nom', 'nom'],
['prenom', 'prénom'],
['appartenance', 'appartenance'],
['courriel', 'courriel'],
['titrePressenti', 'titre pressenti'],
['notes', 'notes'],
]);
const nomChamp = (champ) => nomDans(NOMS_DES_CHAMPS, champ, 'champ');
// Les titres de place qu'un geste sur une personne touche, quand il y en a.
const titresDe = (n, singulier, pluriel) =>
n > 0 ? `, et ${compte(n, 'titre de place', 'titres de place')} ${n <= 1 ? singulier : pluriel}` : '';
export const TABLE = new Map([
// --- Les refus et les avertissements des commandes (§ 4.4, § 10.2) ---------
['NOM_REQUIS', () => 'Le nom est obligatoire.'],
['PARTICIPANT_INCONNU', ({ id }) => `La personne n° ${id} n'est pas dans cet événement.`],
[
'RESERVATIONS_SUSPENDUES',
({ personne, reservations, titres }) =>
`Exclusion de ${personne} : ${compte(reservations, 'réservation suspendue', 'réservations suspendues')}, ` +
`ses places reviennent aux autres${titresDe(titres, "n'est plus pourvu", 'ne sont plus pourvus')}.`,
],
[
'RESERVATIONS_RETABLIES',
({ personne, reservations, titres }) =>
`Réintégration de ${personne} : ${compte(reservations, 'réservation rétablie', 'réservations rétablies')}` +
`${titresDe(titres, 'de nouveau pourvu', 'de nouveau pourvus')}.`,
],
[
'RESERVATIONS_RETIREES',
({ personne, reservations, titres }) =>
`Suppression de ${personne} : ${compte(reservations, 'réservation retirée', 'réservations retirées')}` +
`${titresDe(titres, "n'est plus pourvu", 'ne sont plus pourvus')}.`,
],
[
'IMPORT_EN_RETENU',
() =>
"L'import change la liste après le choix d'un placement : le placement retenu est gardé tel quel, " +
"et rien n'est effacé ; à vous de juger s'il convient encore.",
],
// --- Les libellés figés du journal (§ 8.2, § 8.3) --------------------------
['ENTREE_AJOUTER_PARTICIPANT', ({ personne }) => `Ajouté ${personne}`],
[
'ENTREE_MODIFIER_PARTICIPANT',
({ personne, champs, devenu }) => {
const nouveau = devenu === null || devenu === undefined ? '' : ` (désormais ${devenu})`;
return `Modifié ${personne} : ${champs.map(nomChamp).join(', ')}${nouveau}`;
},
],
['ENTREE_EXCLURE_PARTICIPANT', ({ personne }) => `Exclu ${personne}`],
['ENTREE_REINTEGRER_PARTICIPANT', ({ personne }) => `Réintégré ${personne}`],
['ENTREE_SUPPRIMER_PARTICIPANT', ({ personne }) => `Supprimé ${personne}`],
[
'ENTREE_IMPORTER_PARTICIPANTS',
({ mode, ajoutes, misAJour, retires }) => {
const ajoutesTexte = compte(ajoutes, 'ajouté', 'ajoutés');
if (mode === 'ajouter') return `Importé ${compte(ajoutes, 'participant', 'participants')}`;
if (mode === 'mettreAJour') {
return `Mis à jour la liste par import : ${ajoutesTexte}, ${compte(misAJour, 'modifié', 'modifiés')}`;
}
if (mode === 'remplacer') {
return `Remplacé la liste par import : ${compte(retires, 'retiré', 'retirés')}, ${ajoutesTexte}`;
}
throw new RangeError(`libelle : mode d'import inconnu ${JSON.stringify(mode)}`);
},
],
]);

View file

@ -0,0 +1,6 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les textes des commandes des places — réservations, titres, pose (§ 5.8,
// § 5.9, § 14.6).
export const TABLE = new Map();

View file

@ -0,0 +1,98 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les textes des codes de la séance (§ 8.3 à § 8.8, § 14.6), et les libellés
// figés que la séance écrit au journal : jalons, reprise, retours. Un libellé
// d'entrée se compose une fois, à l'écriture du geste, et le journal en garde
// le texte, jamais le code (§ 8.2).
import { causeEntreParentheses, cite, depuisQuand, nomEtat, ouEcrire } from './aides.js';
export const TABLE = new Map([
// --- La séance (§ 8.3, § 8.4, § 8.5, § 8.6, § 8.8) -------------------------
['AUCUN_EVENEMENT', () => "Aucun événement n'est ouvert."],
['MODE_LECTURE', () => "L'événement est ouvert en lecture : « Modifier » permet de le changer."],
['FORMAT', (details, texte) => texte('FORMAT_PLUS_RECENT', details)],
['BLOQUE', () => 'Le plan est bloqué.'],
// echec nomme ce que le support n'a pas reçu : la ligne d'un geste
// (ECRITURE), l'état d'un geste inscrit au journal (ETAT_NON_ECRIT), le
// jalon d'un geste enregistré (JALON_NON_ECRIT), ou le verrou d'un passage
// en écriture (VERROU_NON_ECRIT), qui ne perd rien.
[
'SUPPORT_ROMPU',
({ echec, ...details }, texte) => {
const refus = `Le support ne prend plus les écritures : ${ouEcrire(details, texte)} n'a pas pu s'écrire`;
const ailleurs = "« Écrire ailleurs » enregistre l'événement dans un autre dossier.";
if (echec === 'ECRITURE' || echec === 'ETAT_NON_ECRIT') {
const perte =
echec === 'ETAT_NON_ECRIT'
? "le dernier geste est inscrit à l'historique, mais pas dans l'état"
: "le dernier geste n'est pas enregistré";
return `${refus}, et ${perte}. Sur ce support, l'historique et l'état ne se correspondent plus ; ${ailleurs}`;
}
if (echec === 'JALON_NON_ECRIT') {
return (
`${refus}. Le dernier geste est enregistré, mais pas le jalon qui le nomme, ` +
`et aucun geste ne s'y enregistrerait plus ; ${ailleurs}`
);
}
if (echec === 'VERROU_NON_ECRIT') {
return (
`${refus}. L'événement y reste tel qu'il s'affiche, ` +
`mais aucun geste ne s'y enregistrerait ; ${ailleurs}`
);
}
throw new RangeError(`libelle : échec du support inconnu ${JSON.stringify(echec)}`);
},
],
['SUPPORT_INTACT', () => "Le support prend encore les écritures : l'événement reste où il est."],
// Quitter l'événement d'un support rompu : la séance tient la seule copie
// que l'écriture ailleurs peut encore sauver ; ETAT_NON_ECRIT dit que son
// dernier geste n'est qu'à l'historique de ce support.
[
'SUPPORT_ROMPU_NON_SAUVE',
({ echec, dossier }) => {
const geste =
echec === 'ETAT_NON_ECRIT' ? ", et son dernier geste n'y est inscrit qu'à l'historique" : '';
return (
`Le support ${cite(dossier)} ne prend plus les écritures${geste} : ` +
"l'événement ouvert n'est sauvé dans aucun autre dossier. " +
"« Écrire ailleurs » l'enregistre d'abord ; le quitter maintenant l'abandonne à ce support."
);
},
],
[
'EVENEMENT_CHANGE',
({ revision }) =>
`Une autre séance a modifié cet événement depuis son ouverture : il s'affiche tel qu'elle l'a laissé, ` +
`à la révision ${revision}, et les gestes suivants s'y ajoutent.`,
],
[
'VERROU_REPRIS',
({ depuis }, texte) => `Le verrou qu'une séance interrompue de ce poste avait laissé${depuisQuand(depuis, texte)} est repris.`,
],
[
'VERROU_NON_RENDU',
({ cause }, texte) =>
`Le verrou de l'événement n'a pas pu être rendu${causeEntreParentheses(cause, texte)} : ` +
'une autre séance de ce poste le reprendra une fois ce logiciel fermé.',
],
['RIEN_A_DEFAIRE', () => "Rien à défaire : l'historique commence à cet instant."],
['RIEN_A_REFAIRE', () => 'Rien à refaire : seul un retour se refait, tant que nul geste ne le suit.'],
['INSTANT_NON_RESTITUABLE', ({ revision }) => `L'historique ne restitue plus l'instant ${revision}.`],
[
'JALON_NON_ECRIT',
({ dossier, cause }, texte) =>
`Le geste est enregistré, mais le jalon qui le nomme n'a pas pu s'écrire ` +
`dans le dossier ${cite(dossier)}${causeEntreParentheses(cause, texte)}.`,
],
['SANS_EFFET', () => "Ce geste ne change rien : rien n'est enregistré."],
// --- Les libellés figés du journal (§ 8.2, § 8.3) --------------------------
['JALON_CREATION', () => 'Création'],
['JALON_ETAT', ({ etat }) => `État ${cite(nomEtat(etat))}`],
['JALON_AVANT_GENERATION', () => 'Avant la génération'],
['ENTREE_REPRISE', () => "Reprise de l'état trouvé sur le disque"],
['ENTREE_DEFAIRE', ({ libelle: instant }) => `Défait : ${cite(instant)}`],
['ENTREE_REFAIRE', ({ libelle: instant }) => `Refait : ${cite(instant)}`],
['ENTREE_REVENIR', ({ libelle: instant }) => `Retour à ${cite(instant)}`],
]);

View file

@ -0,0 +1,319 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les textes des codes de src/stockage (§ 14.6) : le système de fichiers et le
// dépôt, les raisons d'un fichier illisible, les fautes et les raisons de
// dérive d'un placement, ce que l'ouverture annonce, le dossier de travail et
// les réglages locaux. Une raison qu'un texte ne traite pas à part ne lève
// jamais : une phrase générique la nomme, par son texte quand les tables en
// portent un, telle quelle sinon. Une cause que nomme le système, EROFS,
// ENOENT ou TypeMismatchError, n'est pas un code de l'application : les
// textes la citent telle quelle, entre parenthèses.
import {
capitale,
causeEntreParentheses,
cheminDe,
cite,
compte,
depuisQuand,
element,
enumeration,
numeros,
ouEcrire,
phraseDeRaison,
} from './aides.js';
// La raison d'un fichier d'état illisible, en une phrase.
function raisonIllisible({ raison, ...details }, texte) {
if (raison === 'ABSENT') return "Le fichier d'état manque, à côté de son historique.";
return phraseDeRaison(raison, details, texte);
}
// Les secours d'un fichier d'état illisible, quand l'erreur les nomme.
function secoursDe(secours) {
if (secours === undefined || secours === null) return '';
const offerts = [];
if (secours.precedent !== null) offerts.push(`la version précédente du fichier (révision ${secours.precedent})`);
if (secours.journal !== null) offerts.push(`le dernier instant de l'historique (révision ${secours.journal})`);
if (offerts.length === 0) return ' Aucune version de secours ne se lit.';
return ` ${capitale(enumeration(offerts))} ${offerts.length === 1 ? 'peut' : 'peuvent'} se rouvrir.`;
}
// Ce qu'une dérive touche, du retenu aux propositions, et l'accord du verbe.
function touchees({ ids, retenu }) {
const parties = [];
if (retenu) parties.push('le placement retenu');
if (ids.length === 1) parties.push(`la proposition n° ${ids[0]}`);
if (ids.length > 1) parties.push(`les propositions n° ${enumeration(ids.map(String))}`);
return { texte: capitale(enumeration(parties)), pluriel: (retenu ? 1 : 0) + ids.length > 1 };
}
export const TABLE = new Map([
// --- Le système de fichiers et le dépôt (src/stockage/types.js) ------------
[
'ABSENT',
({ chemin }) =>
typeof chemin === 'string' ? `Le fichier ${cite(chemin)} est introuvable.` : "Le fichier d'état manque.",
],
['ECRITURE', (details, texte) => `L'écriture de ${ouEcrire(details, texte)} a échoué ; le fichier d'avant reste intact.`],
['LECTURE', (details, texte) => `La lecture de ${ouEcrire(details, texte)} est refusée.`],
['EXISTE', ({ chemin }) => `Un fichier ${cite(chemin)} existe déjà.`],
['CHEMIN_REFUSE', ({ chemin }) => `Le chemin ${cite(String(chemin))} est refusé.`],
[
'ETAT_ILLISIBLE',
(details, texte) => {
const sujet = typeof details.base === 'string' ? `Le fichier d'état de ${cite(details.base)}` : 'Le fichier';
return `${sujet} ne s'ouvre pas. ${raisonIllisible(details, texte)}${secoursDe(details.secours)}`;
},
],
['CORRECTIF', ({ rang }) => `Un correctif de l'historique ne s'applique pas (opération ${rang}).`],
[
'CHEMIN_TROP_LONG',
({ racine }) =>
`Le dossier ${cite(cheminDe(racine))} est trop profond : aucun nom de fichier n'y tient ` +
'sous la longueur de chemin que Windows accepte.',
],
[
'IDENTIFIANT_PRESENT',
({ base }) =>
`Cet événement est déjà dans ce dossier, sous ${cite(base)} : ` +
"l'y placer une seconde fois en ferait deux copies de même identifiant.",
],
[
'VERROU_PRIS',
({ depuis, vivant }, texte) => {
if (vivant === true) {
return `Une autre séance modifie cet événement${depuisQuand(depuis, texte)} : il reste en lecture ici.`;
}
if (vivant === false) {
return (
`Une séance interrompue a laissé son verrou${depuisQuand(depuis, texte)}, et il n'a pas pu se reprendre : ` +
"l'événement reste en lecture."
);
}
return (
`Un verrou dont la séance ne se laisse pas vérifier — autre poste, verrou illisible, ou séance de ce poste dont la vie ne se prouve pas — tient cet événement${depuisQuand(depuis, texte)} : ` +
'il reste en lecture ici, et rien ne le reprend seul.'
);
},
],
[
'LECTURE_SEULE',
({ raison }, texte) => {
if (raison === 'BLOQUE') {
return `${texte('BLOQUE')} Rien ne peut changer ; « Débloquer » le rend de nouveau modifiable.`;
}
if (raison === 'FORMAT' || raison === 'FORMAT_PLUS_RECENT') {
return `${texte(raison)} Il s'ouvre en lecture seule, et aucun geste ne le modifie ici.`;
}
if (raison === 'ENCODAGE') {
return (
`Le fichier d'état a changé sur le disque. ${texte('ENCODAGE')} ` +
"L'événement reste en lecture seule ; rouvert par une version de secours, son premier geste " +
"range ce fichier dans la corbeille avant d'écrire l'état."
);
}
return `${phraseDeRaison(raison, {}, texte)} L'événement reste en lecture seule, et aucun geste ne le modifie ici.`;
},
],
[
'ETAT_NON_ECRIT',
(details, texte) => `Le geste est inscrit à l'historique, mais l'état ${ouEcrire(details, texte)} n'a pas pu s'écrire.`,
],
[
'CORBEILLE_SATUREE',
({ dossier }) =>
`La corbeille ${cite(dossier)} a reçu trop de suppressions dans la même seconde : ` +
'la suppression se refait une seconde plus tard.',
],
[
'COMPTEUR_SATURE',
({ compteur }) =>
`Les numéros ${compteur === 'proposition' ? 'de proposition' : `du compteur ${cite(compteur)}`} sont épuisés : ` +
'rien de neuf ne se numérote plus dans cet événement.',
],
['NON_DISPONIBLE', ({ cause }, texte) => `Cette commande n'est pas offerte ici${causeEntreParentheses(cause, texte)}.`],
// --- Les raisons d'un fichier illisible ------------------------------------
['VIDE', () => 'Le fichier est vide.'],
['JSON', () => "Le texte n'est pas du JSON valide."],
['FORME', ({ chemin }) => `Une valeur sort de sa règle${element(chemin)}.`],
['COMPTES', ({ chemin }) => `Un compte de l'en-tête contredit sa liste${element(chemin)}.`],
['REFERENCE', ({ chemin }) => `Une réservation ou un titre désigne ce qui n'existe pas${element(chemin)}.`],
['FORMAT_INCONNU', () => "Le format du fichier n'est pas reconnu."],
[
'FORMAT_PLUS_RECENT',
({ format }) => {
const quel = Number.isInteger(format) ? ` (format ${format})` : '';
return `Ce fichier vient d'une version plus récente du logiciel${quel}.`;
},
],
[
'ENCODAGE',
() => "Ses octets ne sont pas de l'UTF-8 : un éditeur l'a sans doute réenregistré dans un autre encodage.",
],
// --- Les fautes d'un placement, et ses raisons de dérive (§ 8.9, § 9) ------
[
'LONGUEUR',
({ tour, table, declare, ecrit }) => {
if (tour === null) return `Le placement déclare ${compte(declare, 'tour', 'tours')} et en porte ${ecrit}.`;
if (table === null) {
return `Au tour ${tour}, le placement déclare ${compte(declare, 'table', 'tables')} et en porte ${ecrit}.`;
}
return (
`Au tour ${tour}, la table ${table} reçoit ${compte(ecrit, 'personne', 'personnes')} ` +
`pour ${compte(declare, 'place', 'places')}.`
);
},
],
['DOUBLON', ({ tour, participant }) => `Au tour ${tour}, la personne n° ${participant} est placée plus d'une fois.`],
[
'INCONNU',
({ tour, participant }) => `Au tour ${tour}, la personne n° ${participant} est placée sans être déclarée.`,
],
['MANQUANT', ({ tour, participant }) => `Au tour ${tour}, la personne n° ${participant} n'est placée nulle part.`],
['IDENTIFIANT_REPETE', () => 'Une autre proposition porte déjà ce numéro.'],
['IDENTIFIANT_HORS_COMPTEUR', () => 'Ce numéro de proposition dépasse le compteur du fichier.'],
['PARTICIPANT_EXCLU', ({ participant }) => `La personne n° ${participant} est exclue depuis.`],
['PARTICIPANT_SUPPRIME', ({ participant }) => `La personne n° ${participant} a été supprimée depuis.`],
[
'PARTICIPANT_NON_PLACE',
({ participant }) => `La personne n° ${participant}, ajoutée ou réintégrée depuis, n'y est pas placée.`,
],
['TABLE_SUPPRIMEE', ({ table }) => `La table d'identifiant ${table} a été supprimée depuis.`],
['TABLE_AJOUTEE', ({ table }) => `La table d'identifiant ${table} a été ajoutée depuis.`],
[
'CAPACITE_CHANGEE',
({ table, avant, maintenant }) =>
`La table d'identifiant ${table} est passée de ${avant} à ${compte(maintenant, 'place', 'places')}.`,
],
['TOURS_CHANGES', ({ avant, maintenant }) => `Le nombre de tours est passé de ${avant} à ${maintenant}.`],
// --- Ce que l'ouverture annonce (§ 8.6, § 8.8, § 8.9, § 9) -----------------
['RETENU_FAUTIF', () => 'Le placement retenu se contredit : la version précédente du fichier peut le reprendre.'],
['JOURNAL_REPRIS', () => "Le dernier geste, inscrit à l'historique mais pas dans l'état, est repris."],
[
'JOURNAL_EN_AVANCE_DE',
({ n }) =>
`L'historique compte ${compte(n, 'geste', 'gestes')} de plus que l'état : ` +
"l'état du disque est affiché, et le prochain geste inscrit une reprise.",
],
[
'JOURNAL_INCOMPLET',
({ etat, journal }) =>
`L'historique s'arrête à la révision ${journal}, l'état est à la révision ${etat} : ` +
'le prochain geste inscrit une reprise.',
],
[
'JOURNAL_ECARTEES',
({ n }) =>
`${compte(n, 'entrée', 'entrées')} de l'historique ${n <= 1 ? 'est écartée' : 'sont écartées'}, ` +
'à partir de la première ligne illisible.',
],
[
'JOURNAL_RECREE',
() =>
"Le fichier de l'historique avait disparu du dossier : il est réécrit en entier, " +
'tel que la séance le connaissait.',
],
[
'JOURNAL_FORMAT_PLUS_RECENT',
({ format }) =>
`L'historique vient d'une version plus récente du logiciel (format ${format}) : ` +
"il ne se lit pas, et l'événement reste en lecture seule.",
],
[
'JOURNAL_ILLISIBLE',
({ raison }, texte) =>
`L'historique à côté de ce fichier ne se lit pas. ${phraseDeRaison(raison, {}, texte)} ` +
"Il est laissé tel quel, et le prochain geste commence l'historique dans un nouveau fichier.",
],
[
'JOURNAL_ETRANGER',
({ evenement }) =>
`L'historique à côté de ce fichier appartient à un autre événement (${evenement}) : il est ignoré.`,
],
[
'PROPOSITIONS_FAUTIVES',
({ ids }) =>
ids.length <= 1
? `Une proposition incohérente est écartée (${numeros(ids)}).`
: `${ids.length} propositions incohérentes sont écartées (${numeros(ids)}).`,
],
[
'PROPOSITIONS_MANQUANTES',
({ n }) =>
`${compte(n, "proposition que l'en-tête annonce manque", "propositions que l'en-tête annonce manquent")} ` +
'dans le fichier : celles qu\'il porte sont gardées.',
],
[
'DERIVE',
(details) => {
const { texte, pluriel } = touchees(details);
return pluriel
? `${texte} ne décrivent plus la liste ou les tables actuelles : ils sont gardés, et rien n'est effacé.`
: `${texte} ne décrit plus la liste ou les tables actuelles : il est gardé, et rien n'est effacé.`;
},
],
// --- Le dossier de travail, les réglages locaux (§ 8.5, § 8.6, § 8.8) ------
['NON_PUBLIE', () => "Le dossier de l'exécutable n'est pas connu : les événements se rangent dans les Documents."],
[
'DONNEES_APPLICATIVES',
() => "L'exécutable est rangé sous les données applicatives : les événements se rangent dans les Documents.",
],
[
'SONDE_ECHOUEE',
({ cause }, texte) =>
`Le dossier data à côté de l'exécutable refuse l'écriture${causeEntreParentheses(cause, texte)} : ` +
'les événements se rangent dans les Documents.',
],
// Une cause de la sonde, que les textes citent entre parenthèses.
['RELECTURE_DIFFERENTE', () => "le fichier d'essai relu ne porte pas ce qui vient d'y être écrit"],
// Une cause de l'OPFS : sous des paramètres régionaux qui ne sont pas
// UTF-8, le navigateur ne nomme pas un dossier ou un fichier hors ASCII.
[
'NOM_NON_CONVERTI',
() =>
"le navigateur ne sait pas nommer un dossier ou un fichier aux caractères accentués sous les réglages de langue du système",
],
[
'PORTABLE_NON_INSCRIPTIBLE',
({ racine, evenements, cause }, texte) => {
const porte =
evenements === null
? 'porte peut-être des événements'
: `porte ${compte(evenements, 'événement', 'événements')}`;
return `Le dossier ${cite(cheminDe(racine))} ${porte}, mais refuse l'écriture${causeEntreParentheses(cause, texte)}.`;
},
],
[
'DOCUMENTS_NON_INSCRIPTIBLE',
({ racine, cause }, texte) =>
`Le dossier de travail ${cite(cheminDe(racine))} refuse l'écriture${causeEntreParentheses(cause, texte)} : ` +
"aucun geste ne s'y enregistrerait.",
],
[
'PLATEFORME_WEB',
() =>
"Sous la plateforme web, ni le renommage atomique ni le verrou n'existent : " +
'une panne peut laisser un fichier incomplet, et deux onglets peuvent écrire le même événement.',
],
// dossier, quand il est donné, nomme le dossier où la séance écrit
// désormais (ecrireAilleurs) ; sans lui, le dossier de travail.
[
'SUPPORT_AMOVIBLE',
({ dossier }) =>
`${typeof dossier === 'string' ? `Le dossier ${cite(dossier)}` : 'Le dossier de travail'} ` +
'est sur un support amovible : retirer le support pendant une écriture peut la laisser incomplète.',
],
[
'REGLAGES_ILLISIBLES',
({ chemin, raison }, texte) => {
const pourquoi =
raison === 'JSON' || raison === 'FORME' ? texte(raison) : `La lecture est refusée (${raison}).`;
return `Les réglages locaux ${cite(chemin)} ne se lisent pas. ${pourquoi} Les valeurs par défaut s'appliquent.`;
},
],
]);

View file

@ -0,0 +1,15 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le nom d'une personne tel que les libellés figés et le plan l'écrivent
// (§ 8.2) : la seule composition de ce nom dans l'application.
/**
* Le prénom, puis le nom ; le nom seul quand le prénom est null.
*
* @param {{nom: string, prenom: string|null}} personne
* @returns {string}
*/
export function nomAffiche({ nom, prenom }) {
return prenom === null ? nom : `${prenom} ${nom}`;
}

View file

@ -0,0 +1,33 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du nom affiché (§ 8.2) : la seule composition du nom d'une
// personne, celle des libellés figés et du plan. Les noms sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { nomAffiche as nomDesCommandes } from './commandes.js';
import { libelle } from './libelles.js';
import { nomAffiche } from './personnes.js';
describe('personnes : le nom affiché (§ 8.2)', () => {
test('prénom puis nom ; le nom seul quand le prénom est null', () => {
assert.equal(nomAffiche({ nom: 'Pervenche', prenom: 'Théo' }), 'Théo Pervenche');
assert.equal(nomAffiche({ nom: 'Grisaille', prenom: null }), 'Grisaille');
});
test('les autres champs de la personne ne comptent pas', () => {
assert.equal(
nomAffiche({ id: 4, nom: 'Lacasse', prenom: 'Ondine', appartenance: 'Club des Merles', exclu: true }),
'Ondine Lacasse',
);
});
test('nomAffiche importé de commandes.js est celui de personnes.js', () => {
assert.equal(nomDesCommandes, nomAffiche);
});
test('un libellé figé qui nomme une personne la nomme par nomAffiche', () => {
const personne = nomAffiche({ nom: 'Ombrelle', prenom: 'Iris' });
assert.equal(libelle('ENTREE_AJOUTER_PARTICIPANT', { personne }), 'Ajouté Iris Ombrelle');
});
});

View file

@ -0,0 +1,73 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le mécanisme de traduction (§ 14.6), unique pour l'application et les
// écrans. Il réunit des tables — chacune une Map clé → chaîne, ou clé →
// fonction (details, texte) → chaîne — et rend t(cle, details), le texte
// d'une clé. Une clé portée par deux tables lève à la création ; une clé
// absente lève RangeError, qui la nomme : l'écran ne montre jamais une clé
// brute ni un texte vide.
//
// texte(cle, details), que reçoit une fonction de table, compose une autre
// clé des mêmes tables, toujours en français ; texte.existe(cle) dit si une
// clé a son texte, pour un détail qui porte un code ou une cause du système.
// En langue témoin, t encadre de la marque son résultat entier, une fois :
// une composition par texte n'en ajoute aucune.
/** Les deux marques de la langue témoin, U+2045 et U+2046. */
export const MARQUE_TEMOIN = Object.freeze({ ouverture: '\u{2045}', fermeture: '\u{2046}' });
const LANGUES = ['fr', 'temoin'];
// Deux chaînes, comparées unité UTF-16 par unité.
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
/**
* La traduction de ces tables, dans cette langue.
*
* @param {Array<Map<string, string|((details: Object, texte: Function) => string)>>} tables
* @param {{langue?: 'fr'|'temoin'}} [options]
* @returns {{t: (cle: string, details?: Object) => string, cles: () => string[], langue: string}}
* t lève RangeError sur une clé absente, TypeError quand une fonction de
* table ne rend pas une chaîne ; cles() rend toutes les clés, triées par
* unités UTF-16
* @throws {RangeError} une clé de deux tables, une langue inconnue
* @throws {TypeError} une table qui n'est pas une Map, une entrée ni chaîne
* ni fonction
*/
export function creerTraduction(tables, { langue = 'fr' } = {}) {
if (!LANGUES.includes(langue)) throw new RangeError(`traduction : langue inconnue ${JSON.stringify(langue)}`);
const entrees = new Map();
for (const table of tables) {
if (!(table instanceof Map)) throw new TypeError('traduction : chaque table est une Map');
for (const [cle, entree] of table) {
if (entrees.has(cle)) {
throw new RangeError(`traduction : la clé ${JSON.stringify(cle)} est portée par deux tables`);
}
if (typeof entree !== 'string' && typeof entree !== 'function') {
throw new TypeError(`traduction : ${JSON.stringify(cle)} n'est ni une chaîne ni une fonction`);
}
entrees.set(cle, entree);
}
}
// Le texte français d'une clé : la chaîne, ou ce que rend la fonction de
// ses détails, un objet vide quand ils manquent.
function texte(cle, details = {}) {
const entree = entrees.get(cle);
if (entree === undefined) throw new RangeError(`traduction : clé inconnue ${JSON.stringify(cle)}`);
if (typeof entree === 'string') return entree;
const rendu = entree(details ?? {}, texte);
if (typeof rendu !== 'string') throw new TypeError(`traduction : ${JSON.stringify(cle)} ne rend pas une chaîne`);
return rendu;
}
texte.existe = (cle) => entrees.has(cle);
const { ouverture, fermeture } = MARQUE_TEMOIN;
const t =
langue === 'temoin'
? (cle, details) => `${ouverture}${texte(cle, details)}${fermeture}`
: (cle, details) => texte(cle, details);
const triees = Object.freeze([...entrees.keys()].sort(comparerTextes));
return { t, cles: () => [...triees], langue };
}

View file

@ -0,0 +1,111 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du mécanisme de traduction (§ 14.6) : une clé absente lève et se
// nomme, une clé de deux tables lève à la création, les clés s'énumèrent
// triées, une entrée en compose une autre, et la langue témoin encadre le
// résultat entier de t d'une seule paire de marques. Les tables d'épreuve sont
// inventées.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { MARQUE_TEMOIN, creerTraduction } from './traduction.js';
const { ouverture: O, fermeture: F } = MARQUE_TEMOIN;
const TABLE_A = new Map([
['essai.vide', '\u{2014}'],
['essai.salut', ({ nom }) => `Bonjour ${nom}`],
['essai.compose', ({ nom }, texte) => `${texte('essai.salut', { nom })}, ${texte('essai.vide')}`],
]);
const TABLE_B = new Map([
['AUTRE_CODE', () => 'Un code d’essai.'],
['autre.libelle', 'Un libellé'],
]);
describe('traduction : le mécanisme (§ 14.6)', () => {
test('les deux marques de la langue témoin sont U+2045 et U+2046, et figées', () => {
assert.equal(O, '\u{2045}');
assert.equal(F, '\u{2046}');
assert.ok(Object.isFrozen(MARQUE_TEMOIN));
});
test('une clé absente lève RangeError, et le message nomme la clé ; jamais la clé brute, jamais une chaîne vide', () => {
const { t } = creerTraduction([TABLE_A]);
assert.throws(() => t('essai.inconnue'), (erreur) => {
assert.ok(erreur instanceof RangeError);
assert.match(erreur.message, /essai\.inconnue/);
return true;
});
assert.throws(() => t(undefined), RangeError);
assert.throws(() => t('constructor'), RangeError);
});
test('une clé portée par deux tables lève à la création, et se nomme', () => {
const doublon = new Map([['essai.salut', 'Salut']]);
assert.throws(() => creerTraduction([TABLE_A, doublon]), (erreur) => {
assert.ok(erreur instanceof RangeError);
assert.match(erreur.message, /essai\.salut/);
return true;
});
});
test('cles() énumère toutes les clés des tables, triées par unités UTF-16', () => {
const { cles } = creerTraduction([TABLE_A, TABLE_B]);
const attendues = ['AUTRE_CODE', 'autre.libelle', 'essai.compose', 'essai.salut', 'essai.vide'];
assert.equal(cles().length, 5);
assert.deepEqual(cles(), attendues);
});
test('une chaîne se rend telle quelle, une fonction reçoit ses détails, et une entrée en compose une autre par texte', () => {
const { t, langue } = creerTraduction([TABLE_A, TABLE_B]);
assert.equal(langue, 'fr');
assert.equal(t('autre.libelle'), 'Un libellé');
assert.equal(t('essai.salut', { nom: 'Iris Ombrelle' }), 'Bonjour Iris Ombrelle');
assert.equal(t('essai.compose', { nom: 'Théo Pervenche' }), 'Bonjour Théo Pervenche, \u{2014}');
assert.equal(t('AUTRE_CODE'), 'Un code d’essai.');
});
test("une fonction appelée sans détails en reçoit un objet vide ; une entrée qui ne rend pas une chaîne lève", () => {
const table = new Map([
['essai.details', (details) => `${Object.keys(details).length}`],
['essai.nombre', () => 3],
]);
const { t } = creerTraduction([table]);
assert.equal(t('essai.details'), '0');
assert.equal(t('essai.details', null), '0');
assert.throws(() => t('essai.nombre'), TypeError);
});
test("une table qui n'est pas une Map, une entrée ni chaîne ni fonction, une langue inconnue lèvent à la création", () => {
assert.throws(() => creerTraduction([{ 'essai.objet': 'x' }]), TypeError);
assert.throws(() => creerTraduction([new Map([['essai.nombre', 4]])]), TypeError);
assert.throws(() => creerTraduction([TABLE_A], { langue: 'en' }), RangeError);
});
});
describe('traduction : la langue témoin (§ 14.6)', () => {
test('t encadre son résultat de la marque, une fois', () => {
const { t, langue } = creerTraduction([TABLE_A, TABLE_B], { langue: 'temoin' });
assert.equal(langue, 'temoin');
assert.equal(t('essai.vide'), `${O}\u{2014}${F}`);
assert.equal(t('autre.libelle'), `${O}Un libellé${F}`);
});
test('une fonction de détails est décorée sur son résultat entier', () => {
const { t } = creerTraduction([TABLE_A], { langue: 'temoin' });
assert.equal(t('essai.salut', { nom: 'Ondine Lacasse' }), `${O}Bonjour Ondine Lacasse${F}`);
});
test("une composition ne porte qu'une paire de marques : texte compose en français", () => {
const { t } = creerTraduction([TABLE_A], { langue: 'temoin' });
const rendu = t('essai.compose', { nom: 'Grisaille' });
assert.equal(rendu, `${O}Bonjour Grisaille, \u{2014}${F}`);
assert.equal(rendu.split(O).length - 1, 1);
assert.equal(rendu.split(F).length - 1, 1);
});
test('une clé absente lève en langue témoin comme en français', () => {
const { t } = creerTraduction([TABLE_A], { langue: 'temoin' });
assert.throws(() => t('essai.inconnue'), RangeError);
});
});

158
src/geometrie/constantes.js Normal file
View file

@ -0,0 +1,158 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le registre des constantes mesurées (§ 14.1, § 19.10). Toute dimension qui
// dépend du rendu ou du geste se relève, jamais ne se déduit : chaque entrée
// dit ce qu'elle mesure, pourquoi elle existe, sur quelle configuration elle
// se relève, qui la relève et comment. Tant que son propriétaire ne l'a pas
// relevée, une constante porte sa valeur de départ et se déclare défaut de
// travail ; il écrit ici la valeur relevée, passe le statut à « mesuree » et
// complète la configuration. Le banc de mesure lit ce tableau pour en
// engendrer la région de la documentation (§ 19.10).
/**
* @typedef {Object} Constante
* @property {string} nom
* @property {number|null} valeur null pour une constante relevée à l'exécution
* @property {string} unite
* @property {string} mesure ce qu'elle mesure
* @property {string} raison pourquoi elle existe
* @property {string} configuration sur quoi elle se relève : machine, cadre, configuration
* @property {'banc'|'node'|'manuel'|'execution'} proprietaire
* @property {'mesuree'|'defautDeTravail'} statut
* @property {string} protocole comment le propriétaire la relève, et où il l'écrit
*/
const PROPRIETAIRES = new Set(['banc', 'node', 'manuel', 'execution']);
const STATUTS = new Set(['mesuree', 'defautDeTravail']);
/** @type {ReadonlyArray<Readonly<Constante>>} */
export const CONSTANTES = Object.freeze(
[
{
nom: 'SEUIL_GLISSER_PX',
valeur: 4,
unite: 'px',
mesure: "déplacement du pointeur, entre pointerdown et pointerup, au-delà duquel un appui devient un glissement",
raison: "un clic délibéré bouge de quelques pixels : sous ce seuil, il resterait un clic et ne déplacerait rien (§ 8.4)",
configuration: 'pavé tactile de la machine de référence',
proprietaire: 'manuel',
statut: 'defautDeTravail',
protocole:
'vingt clics délibérés au pavé tactile de la machine de référence ; le plus grand déplacement entre ' +
'pointerdown et pointerup, × 1,5, écrit dans ce registre',
},
{
nom: 'DUREE_PRESSION_LONGUE_MS',
valeur: 500,
unite: 'ms',
mesure: "durée d'un appui immobile au-delà de laquelle il ouvre le menu d'une personne",
raison: "un appui bref ne doit jamais ouvrir le menu, ni une pression voulue se faire attendre (§ 7.3)",
configuration: 'écran tactile de la machine de référence',
proprietaire: 'manuel',
statut: 'defautDeTravail',
protocole:
"à l'écran tactile de la machine de référence, la plus courte durée qu'aucun appui bref ne déclenche, " +
'sur dix essais, écrite dans ce registre',
},
{
nom: 'SEUIL_SIEGES_LISIBLES',
valeur: 16,
unite: 'places',
mesure: "plus grand nombre de places d'une table dont la liste de noms reste lisible",
raison: "au-delà, le dessin d'une table cesse d'être lisible, et l'application le signale (§ 6.2)",
configuration: 'banc de mesure, cadre de 1 366 × 700 px',
proprietaire: 'banc',
statut: 'defautDeTravail',
protocole:
'banc de mesure, cadre de 1 366 × 700 px : le plus grand nombre de places dont la liste, à la hauteur ' +
"de ligne mesurée, tient dans la hauteur du cadre au zoom ajusté d'une table seule ; écrit dans ce registre",
},
{
nom: 'K_LISTES',
valeur: 0.3,
unite: 'px/cm',
mesure: 'échelle sous laquelle le plan retire les listes de noms',
raison: "sous elle, dessiner les listes coûte plus d'une image par déplacement de vue (§ 17, point 6)",
configuration: 'banc de mesure, grande démonstration, 33 tables avec listes',
proprietaire: 'banc',
statut: 'defautDeTravail',
protocole:
'banc de mesure : temps par image pendant un déplacement de vue sur la grande démonstration, 33 tables ' +
'avec listes, à plusieurs k ; le plus petit k qui tient le critère de temps par image, écrit dans ce registre',
},
{
nom: 'LARGEUR_BLOC_NOMS_CM',
valeur: 180,
unite: 'cm',
mesure: "largeur, dans le dessin, du bloc de noms d'une table, où un nom trop long s'abrège",
raison: 'un nom tient sur une ligne du plan sans recouvrir la table voisine (§ 7.4)',
configuration: 'grande démonstration au zoom par défaut',
proprietaire: 'manuel',
statut: 'defautDeTravail',
protocole:
"relecture de la grande démonstration au zoom par défaut par une personne qui n'a pas écrit le " +
'logiciel (§ 19.11) ; la largeur retenue, écrite dans ce registre',
},
{
nom: 'HAUTEUR_LIGNE',
valeur: null,
unite: 'cm',
mesure: "hauteur d'une ligne d'une liste de noms, dans le dessin",
raison: 'deux lignes ne se chevauchent pas, quels que soient les accents et les jambages (§ 7.4)',
configuration: 'police calculée des listes, relevée à chaque montage du plan',
proprietaire: 'execution',
statut: 'mesuree',
protocole:
'mesurée à chaque montage du plan : measureText du gabarit « Ôjgq Æ W » sur la police calculée des ' +
"listes ; aucune valeur n'est écrite dans ce registre",
},
].map((entree) => Object.freeze(entree)),
);
// Champs textuels d'une entrée, qui ne peuvent être vides.
const CHAMPS_TEXTE = ['unite', 'mesure', 'raison', 'configuration'];
const estTexte = (valeur) => typeof valeur === 'string' && valeur.length > 0;
/**
* La valeur de la constante nommée ; null pour une constante relevée à
* l'exécution. Lève RangeError sur un nom absent du registre.
* @param {string} nom
* @returns {number|null}
*/
export function constante(nom) {
const entree = CONSTANTES.find((c) => c.nom === nom);
if (entree === undefined) throw new RangeError(`constante inconnue : ${String(nom)}`);
return entree.valeur;
}
/**
* Les fautes d'un registre, [] quand chaque entrée est complète : une par
* champ fautif, dans l'ordre des entrées, puis dans l'ordre des champs du
* type Constante. Un nom vide, ou déjà porté par une entrée précédente, est
* une faute ; la valeur est un nombre fini, et null pour une constante
* relevée à l'exécution seulement ; les champs textuels ne sont pas vides ;
* propriétaire et statut prennent une valeur de leur liste.
* @param {ReadonlyArray<Constante>} liste
* @returns {Array<{nom: string, champ: string}>}
*/
export function verifierRegistre(liste) {
const vus = new Set();
const fautes = [];
for (const entree of liste) {
const nom = entree.nom;
const faute = (champ) => fautes.push({ nom, champ });
if (!estTexte(nom) || vus.has(nom)) faute('nom');
vus.add(nom);
const execution = entree.proprietaire === 'execution';
if (execution ? entree.valeur !== null : !Number.isFinite(entree.valeur)) faute('valeur');
for (const champ of CHAMPS_TEXTE) {
if (!estTexte(entree[champ])) faute(champ);
}
if (!PROPRIETAIRES.has(entree.proprietaire)) faute('proprietaire');
if (!STATUTS.has(entree.statut)) faute('statut');
if (!estTexte(entree.protocole)) faute('protocole');
}
return fautes;
}

View file

@ -0,0 +1,96 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du registre des constantes mesurées (§ 14.1, § 19.10) : les six
// entrées du contrat de données, chacune complète, et la vérification qui
// nomme l'entrée et le champ d'une faute.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { CONSTANTES, constante, verifierRegistre } from './constantes.js';
// Le contrat de données : nom, valeur de départ, unité, propriétaire.
const CONTRAT = [
['SEUIL_GLISSER_PX', 4, 'px', 'manuel'],
['DUREE_PRESSION_LONGUE_MS', 500, 'ms', 'manuel'],
['SEUIL_SIEGES_LISIBLES', 16, 'places', 'banc'],
['K_LISTES', 0.3, 'px/cm', 'banc'],
['LARGEUR_BLOC_NOMS_CM', 180, 'cm', 'manuel'],
['HAUTEUR_LIGNE', null, 'cm', 'execution'],
];
// Une copie du registre, entrée par entrée, que l'épreuve peut altérer.
const copie = () => CONSTANTES.map((entree) => ({ ...entree }));
describe('constantes : le registre (§ 14.1, § 19.10)', () => {
test('le registre porte les six entrées du contrat, dans son ordre, chacune complète', () => {
assert.equal(CONSTANTES.length, 6);
assert.deepEqual(
CONSTANTES.map(({ nom, valeur, unite, proprietaire }) => [nom, valeur, unite, proprietaire]),
CONTRAT,
);
assert.deepEqual(verifierRegistre(CONSTANTES), []);
for (const entree of CONSTANTES) {
assert.ok(entree.protocole.length > 0 && entree.mesure.length > 0 && entree.raison.length > 0, entree.nom);
}
});
test('tant que son propriétaire ne l\'a pas relevée, une constante se déclare défaut de travail', () => {
const statuts = CONSTANTES.map(({ nom, statut }) => [nom, statut]);
assert.deepEqual(statuts.slice(0, 5), CONTRAT.slice(0, 5).map(([nom]) => [nom, 'defautDeTravail']));
});
test('constante rend la valeur par le nom ; un nom inconnu lève', () => {
assert.equal(constante('SEUIL_GLISSER_PX'), 4);
assert.equal(constante('K_LISTES'), 0.3);
assert.equal(constante('LARGEUR_BLOC_NOMS_CM'), 180);
assert.equal(constante('HAUTEUR_LIGNE'), null);
assert.throws(() => constante('INCONNUE'), RangeError);
assert.throws(() => constante('nom'), RangeError);
});
test('le registre et ses entrées sont figés', () => {
assert.ok(Object.isFrozen(CONSTANTES));
assert.ok(CONSTANTES.every((entree) => Object.isFrozen(entree)));
});
test("verifierRegistre nomme l'entrée et le champ d'une faute : propriétaire, configuration ou statut perdu", () => {
for (const champ of ['proprietaire', 'configuration', 'statut']) {
const liste = copie();
delete liste[2][champ];
assert.deepEqual(verifierRegistre(liste), [{ nom: 'SEUIL_SIEGES_LISIBLES', champ }], champ);
}
const vide = copie();
vide[0].configuration = '';
vide[4].proprietaire = 'personne';
vide[1].statut = 'mesure';
assert.deepEqual(verifierRegistre(vide), [
{ nom: 'SEUIL_GLISSER_PX', champ: 'configuration' },
{ nom: 'DUREE_PRESSION_LONGUE_MS', champ: 'statut' },
{ nom: 'LARGEUR_BLOC_NOMS_CM', champ: 'proprietaire' },
]);
});
test("verifierRegistre relève aussi une valeur absente hors de l'exécution, un texte vide, un nom répété", () => {
const liste = copie();
liste[0].valeur = null;
liste[1].valeur = Number.NaN;
liste[2].protocole = '';
liste[3].unite = undefined;
liste[4].mesure = 3;
liste[5].raison = '';
liste.push({ ...liste[3], unite: 'px/cm' });
assert.deepEqual(verifierRegistre(liste), [
{ nom: 'SEUIL_GLISSER_PX', champ: 'valeur' },
{ nom: 'DUREE_PRESSION_LONGUE_MS', champ: 'valeur' },
{ nom: 'SEUIL_SIEGES_LISIBLES', champ: 'protocole' },
{ nom: 'K_LISTES', champ: 'unite' },
{ nom: 'LARGEUR_BLOC_NOMS_CM', champ: 'mesure' },
{ nom: 'HAUTEUR_LIGNE', champ: 'raison' },
{ nom: 'K_LISTES', champ: 'nom' },
]);
assert.deepEqual(verifierRegistre([{ ...liste[0], nom: '', valeur: 4 }]), [{ nom: '', champ: 'nom' }]);
// Une constante relevée à l'exécution n'a pas de valeur dans le registre :
// un nombre y serait le relevé d'une autre machine.
assert.deepEqual(verifierRegistre([{ ...CONSTANTES[5], valeur: 21.5 }]), [{ nom: 'HAUTEUR_LIGNE', champ: 'valeur' }]);
});
});

View file

@ -0,0 +1,75 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La désignation d'un siège (§ 7.3, § 14.10) : ce que contient un point de
// dessin, lu sur les géométries de tables.js, celles mêmes que le dessin pose.
// Le dépôt d'un glissement ne cherche pas sa cible dans le DOM : il convertit
// le point en coordonnées de dessin et le passe à designer.
/** Taille d'écran minimale d'une cible de siège, en px (§ 7.3). */
export const CIBLE_MIN_PX = 24;
// Vrai quand le point est dans le disque (ronde) ou le carré aux côtés
// parallèles aux axes (carrée) de demi-taille donnée, centré sur la table,
// bord compris.
function dans(point, { centre, forme }, demiTaille) {
const dx = point.x - centre.x;
const dy = point.y - centre.y;
return forme === 'ronde' ? Math.hypot(dx, dy) <= demiTaille : Math.max(Math.abs(dx), Math.abs(dy)) <= demiTaille;
}
/**
* Régime des cibles d'une table au zoom k : 'sieges' quand k × pas ≥
* CIBLE_MIN_PX, 'table' sinon. Le seuil se décide table par table, sur le
* pas de chacune.
*
* @param {number} k px/cm
* @param {import('./tables.js').GeometrieTable} geometrie
* @returns {'sieges'|'table'}
*/
export function regimeCibles(k, geometrie) {
return k * geometrie.pas >= CIBLE_MIN_PX ? 'sieges' : 'table';
}
/**
* Ce que désigne un point de dessin au zoom k.
*
* Les tables s'examinent de la dernière reçue à la première, l'ordre inverse
* de la peinture : la première dont la surface peinte contient le point
* décide. En régime table, la surface est l'emprise — disque d'une ronde,
* carré d'une carrée — et la table est désignée. En régime sièges, elle est
* le plateau et les chaises : un point dans une chaise désigne son siège ;
* un point sur le plateau ne désigne rien, et cache les chaises des tables
* peintes dessous ; entre les chaises, le point passe aux tables de dessous.
* Deux chaises d'une même table ne se recouvrent pas.
*
* @param {{x: number, y: number}} point cm
* @param {import('./tables.js').GeometrieTable[]} geometries dans l'ordre du dessin
* @param {number} k px/cm
* @returns {{table: number, siege: number}|{table: number}|null}
*/
export function designer(point, geometries, k) {
for (let i = geometries.length - 1; i >= 0; i -= 1) {
const geometrie = geometries[i];
if (regimeCibles(k, geometrie) === 'table') {
if (dans(point, geometrie, geometrie.rayonEmprise)) return { table: geometrie.id };
continue;
}
const chaise = geometrie.chaises.find((c) => Math.hypot(point.x - c.x, point.y - c.y) <= c.rayon);
if (chaise !== undefined) return { table: geometrie.id, siege: chaise.siege };
if (dans(point, geometrie, geometrie.demiTaille)) return null;
}
return null;
}
/**
* Vrai quand le point n'est dans l'emprise — disque ou carré — d'aucune
* table : le sol, où un dépôt retire la personne du tour.
*
* @param {{x: number, y: number}} point cm
* @param {import('./tables.js').GeometrieTable[]} geometries
* @returns {boolean}
*/
export function surLeSol(point, geometries) {
return !geometries.some((geometrie) => dans(point, geometrie, geometrie.rayonEmprise));
}

View file

@ -0,0 +1,188 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la désignation (§ 7.3, § 14.10) : le régime des cibles, décidé
// table par table sur le pas de chacune, et le siège que désigne un point de
// dessin, qui est celui que geometrieTable pose — sur les quatre
// démonstrations livrées et sur des carrées, à un zoom où les sièges sont
// des cibles et à un zoom où la table l'est.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { LIVREES } from '../demo/livrees.js';
import { analyser, capacite } from '../stockage/document.js';
import { CIBLE_MIN_PX, designer, regimeCibles, surLeSol } from './designation.js';
import { englobant, geometrieTable } from './tables.js';
const table = (id, forme, x, y) => ({ id, numero: id, sieges: null, forme, position: { x, y } });
const milieu = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
describe('regimeCibles (§ 7.3)', () => {
test('le seuil est de 24 px', () => {
assert.equal(CIBLE_MIN_PX, 24);
});
test('ronde de 8, pas 60 : 0,40 px/cm et au-dessus, les sièges', () => {
const g = geometrieTable(table(1, 'ronde', 0, 0), 8);
assert.equal(regimeCibles(0.41, g), 'sieges');
assert.equal(regimeCibles(0.4, g), 'sieges');
assert.equal(regimeCibles(0.39, g), 'table');
});
test('ronde de 3, pas 70π/3 : bascule entre 0,33 et 0,32', () => {
const g = geometrieTable(table(1, 'ronde', 0, 0), 3);
assert.equal(regimeCibles(0.33, g), 'sieges');
assert.equal(regimeCibles(0.32, g), 'table');
});
test('carrée de 3, pas 70 : bascule entre 0,35 et 0,34', () => {
const g = geometrieTable(table(1, 'carree', 0, 0), 3);
assert.equal(regimeCibles(0.35, g), 'sieges');
assert.equal(regimeCibles(0.34, g), 'table');
});
test('ronde de 7, pas 60 exactement : sièges à 0,40', () => {
assert.equal(regimeCibles(0.4, geometrieTable(table(1, 'ronde', 0, 0), 7)), 'sieges');
});
});
describe('l’emprise d’une carrée est un carré', () => {
// Carrée de 12 au centre (0, 0) : côté 180, emprise 90 + 44 = 134 ; le
// siège 6, dernier du côté droit, est en (112, 60).
const geometries = [geometrieTable(table(7, 'carree', 0, 0), 12)];
test('(125, 125) : hors du plateau et des chaises, dans le coin de l’emprise', () => {
const point = { x: 125, y: 125 };
assert.deepEqual(designer(point, geometries, 0.2), { table: 7 });
assert.equal(designer(point, geometries, 1), null);
assert.equal(surLeSol(point, geometries), false);
});
test('(130, 60) : dans la chaise du siège 6, à 143 cm du centre', () => {
const point = { x: 130, y: 60 };
assert.deepEqual(designer(point, geometries, 0.2), { table: 7 });
assert.deepEqual(designer(point, geometries, 1), { table: 7, siege: 6 });
assert.equal(surLeSol(point, geometries), false);
});
test('(140, 0) est le sol', () => {
const point = { x: 140, y: 0 };
assert.equal(designer(point, geometries, 0.2), null);
assert.equal(designer(point, geometries, 1), null);
assert.equal(surLeSol(point, geometries), true);
});
});
// Les géométries de chaque démonstration livrée, telles que le dessin les
// pose, puis trois carrées de 3, 8 et 12 : les démonstrations n'ont que des
// rondes.
const JEUX = [
...LIVREES.map(({ cle, texte }) => {
const { charge } = analyser(texte);
return { nom: cle, geometries: charge.tables.map((t) => geometrieTable(t, capacite(charge, t))) };
}),
{
nom: 'carrées',
geometries: [
geometrieTable(table(1, 'carree', 0, 0), 3),
geometrieTable(table(2, 'carree', 1000, 0), 8),
geometrieTable(table(3, 'carree', 2000, 0), 12),
],
},
];
describe('le siège désigné est celui que le dessin pose (§ 7.3, § 14.10)', () => {
const ATTENDUES = { grande: 260, 'grande-sans-exception': 264, petite: 12, 'petite-conflit': 12, 'carrées': 23 };
test('cinq jeux, dans l’ordre', () => {
assert.deepEqual(JEUX.map(({ nom }) => nom), ['grande', 'grande-sans-exception', 'petite', 'petite-conflit', 'carrées']);
});
for (const { nom, geometries } of JEUX) {
test(`${nom}, k = 1 : chaque chaise rend sa table et son siège`, () => {
let chaises = 0;
for (const g of geometries) {
assert.equal(regimeCibles(1, g), 'sieges');
for (const chaise of g.chaises) {
assert.deepEqual(designer(chaise, geometries, 1), { table: g.id, siege: chaise.siege });
chaises += 1;
}
}
assert.ok(ATTENDUES[nom] > 0);
assert.equal(chaises, ATTENDUES[nom]);
});
test(`${nom}, k = 1 : ni le milieu de deux chaises voisines, ni le centre d’une table ne désigne rien`, () => {
let milieux = 0;
for (const g of geometries) {
const n = g.chaises.length;
for (let i = 0; i < n; i += 1) {
const point = milieu(g.chaises[i], g.chaises[(i + 1) % n]);
assert.equal(designer(point, geometries, 1), null, `table ${g.id}, sièges ${i + 1} et ${((i + 1) % n) + 1}`);
assert.equal(surLeSol(point, geometries), false);
milieux += 1;
}
assert.equal(designer(g.centre, geometries, 1), null, `centre de la table ${g.id}`);
assert.equal(surLeSol(g.centre, geometries), false);
}
assert.equal(milieux, ATTENDUES[nom]);
});
test(`${nom} : un point à 10 m de tout est le sol`, () => {
const e = englobant(geometries);
const loin = { x: e.maxX + 1000, y: e.maxY + 1000 };
assert.equal(surLeSol(loin, geometries), true);
assert.equal(designer(loin, geometries, 1), null);
assert.equal(designer(loin, geometries, 0.2), null);
});
test(`${nom}, k = 0,2 : chaque chaise, chaque milieu et chaque centre rendent la table, jamais un siège`, () => {
let points = 0;
for (const g of geometries) {
assert.equal(regimeCibles(0.2, g), 'table');
const n = g.chaises.length;
const candidats = [g.centre, ...g.chaises, ...g.chaises.map((c, i) => milieu(c, g.chaises[(i + 1) % n]))];
for (const point of candidats) {
assert.deepEqual(designer(point, geometries, 0.2), { table: g.id });
points += 1;
}
}
assert.equal(points, 2 * ATTENDUES[nom] + geometries.length);
});
}
});
describe('tables qui se recouvrent : la dernière reçue, peinte au-dessus', () => {
test('deux tables au même centre : la seconde', () => {
const geometries = [geometrieTable(table(1, 'ronde', 0, 0), 8), geometrieTable(table(2, 'ronde', 0, 0), 8)];
const chaise = geometries[0].chaises[2];
assert.deepEqual(designer(chaise, geometries, 1), { table: 2, siege: 3 });
assert.deepEqual(designer({ x: 0, y: 0 }, geometries, 0.2), { table: 2 });
});
test('régime sièges : le plateau du dessus cache la chaise de dessous', () => {
// Rondes de 8 en (0, 0) et (100, 0) : le siège 3 de la première, en
// (98,394 ; 0), tombe dans le plateau de la seconde, de rayon 76,394.
const premiere = geometrieTable(table(1, 'ronde', 0, 0), 8);
const seconde = geometrieTable(table(2, 'ronde', 100, 0), 8);
const cachee = premiere.chaises[2];
assert.equal(designer(cachee, [premiere, seconde], 1), null);
assert.deepEqual(designer(cachee, [seconde, premiere], 1), { table: 1, siege: 3 });
assert.deepEqual(designer(premiere.chaises[6], [premiere, seconde], 1), { table: 1, siege: 7 });
});
test('régimes différents : chaque table se juge sur son propre pas', () => {
// À k = 0,35, la ronde de 8 (pas 60) est en régime table, la carrée de 3
// (pas 70) en régime sièges.
const ronde = geometrieTable(table(1, 'ronde', 0, 0), 8);
const carree = geometrieTable(table(2, 'carree', 1000, 0), 3);
const geometries = [ronde, carree];
assert.deepEqual(designer(ronde.chaises[0], geometries, 0.35), { table: 1 });
assert.deepEqual(designer(carree.chaises[0], geometries, 0.35), { table: 2, siege: 1 });
});
test('sans table, aucun point n’est désigné et tout est le sol', () => {
assert.equal(designer({ x: 0, y: 0 }, [], 1), null);
assert.equal(surLeSol({ x: 0, y: 0 }, []), true);
});
});

40
src/geometrie/echelle.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)
// L'échelle graphique du plan (§ 7.7) : une barre d'une longueur ronde, que le
// lecteur reporte sur le dessin. Une barre reste juste quand une impression
// change le facteur d'échelle, là où un rapport écrit en chiffres deviendrait
// faux.
// Mantisses des longueurs rondes, de la plus grande à la plus petite.
const MANTISSES = [5, 2, 1];
// m × 10ⁿ, au plus près : une puissance négative divise par 10⁻ⁿ, que
// l'arithmétique flottante représente exactement, plutôt que de multiplier
// par 10ⁿ, qu'elle arrondit.
const ronde = (m, n) => (n >= 0 ? m * 10 ** n : m / 10 ** -n);
/**
* La plus grande longueur ronde — 1, 2 ou 5 × 10ⁿ cm, n entier de signe
* quelconque — dont la barre tient dans largeurMaxPx à l'échelle k, et la
* largeur de cette barre, longueurCm × k. La recherche part de la décade
* au-dessus de largeurMaxPx / k et descend, mantisse par mantisse, jusqu'à la
* première longueur qui tient : l'arrondi du logarithme ne fait rien manquer.
* Lève RangeError quand k ou largeurMaxPx n'est pas un nombre fini > 0.
* @param {number} k px/cm
* @param {number} largeurMaxPx
* @returns {{longueurCm: number, largeurPx: number}}
*/
export function echelleGraphique(k, largeurMaxPx) {
for (const [nom, valeur] of [['k', k], ['largeur', largeurMaxPx]]) {
if (typeof valeur !== 'number' || !Number.isFinite(valeur) || valeur <= 0) {
throw new RangeError(`${nom} : nombre fini > 0 attendu, reçu ${String(valeur)}`);
}
}
for (let n = Math.floor(Math.log10(largeurMaxPx / k)) + 1; ; n -= 1) {
for (const m of MANTISSES) {
const longueurCm = ronde(m, n);
if (longueurCm * k <= largeurMaxPx) return { longueurCm, largeurPx: longueurCm * k };
}
}
}

View file

@ -0,0 +1,55 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de l'échelle graphique (§ 7.7) : la plus grande longueur ronde, 1,
// 2 ou 5 × 10ⁿ cm, dont la barre tient dans la largeur donnée à l'échelle k.
// Les cas chiffrés sont écrits en clair ; le balayage de k vérifie chaque
// résultat par la définition, non par une seconde implémentation.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { echelleGraphique } from './echelle.js';
// Vrai quand longueur vaut 1, 2 ou 5 × 10ⁿ, n entier de signe quelconque,
// à l'arrondi des nombres flottants près.
function estRonde(longueur) {
const puissance = 10 ** Math.floor(Math.log10(longueur) + 1e-12);
const mantisse = longueur / puissance;
return [1, 2, 5].some((m) => Math.abs(mantisse - m) < 1e-9);
}
// La valeur ronde qui suit longueur : 1 → 2, 2 → 5, 5 → 10.
function rondeSuivante(longueur) {
const puissance = 10 ** Math.floor(Math.log10(longueur) + 1e-12);
const mantisse = Math.round(longueur / puissance);
return (mantisse === 1 ? 2 : mantisse === 2 ? 5 : 10) * puissance;
}
describe("échelle : l'échelle graphique (§ 7.7)", () => {
test('k = 1, 0,19 et 10 dans 200 px', () => {
assert.deepEqual(echelleGraphique(1, 200), { longueurCm: 200, largeurPx: 200 });
const etroite = echelleGraphique(0.19, 200);
assert.equal(etroite.longueurCm, 1000);
assert.ok(Math.abs(etroite.largeurPx - 190) < 1e-9, `${etroite.largeurPx}`);
assert.deepEqual(echelleGraphique(10, 200), { longueurCm: 20, largeurPx: 200 });
assert.deepEqual(echelleGraphique(10, 5), { longueurCm: 0.5, largeurPx: 5 });
});
test('pour chaque k de 0,05 à 10, raison 1,07 : longueur ronde, barre dans 200 px, la ronde suivante dépasserait', () => {
let comptes = 0;
for (let k = 0.05; k <= 10; k *= 1.07) {
const { longueurCm, largeurPx } = echelleGraphique(k, 200);
assert.ok(estRonde(longueurCm), `k = ${k} : ${longueurCm}`);
assert.ok(Math.abs(largeurPx - longueurCm * k) < 1e-9, `k = ${k}`);
assert.ok(largeurPx <= 200, `k = ${k} : ${largeurPx}`);
assert.ok(rondeSuivante(longueurCm) * k > 200, `k = ${k} : ${longueurCm}`);
comptes += 1;
}
assert.equal(comptes, 79);
});
test("k ou largeur qui n'est pas un nombre fini positif est refusé", () => {
for (const [k, largeur] of [[0, 200], [-1, 200], [Number.NaN, 200], [Number.POSITIVE_INFINITY, 200], [1, 0], [1, -5], [1, Number.NaN], [null, 200]]) {
assert.throws(() => echelleGraphique(k, largeur), RangeError, `${k}, ${largeur}`);
}
});
});

239
src/geometrie/tables.js Normal file
View file

@ -0,0 +1,239 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La géométrie d'une table (§ 7.1, § 7.7), en centimètres de dessin, l'axe y
// vers le bas : plateau tiré du nombre de places, chaises numérotées depuis le
// haut dans le sens horaire, emprise, poignée et origine du bloc de noms. Le
// dessin pose les chaises par geometrieTable et la désignation d'un siège
// (designation.js) lit les mêmes chaises : une seule implémentation, que le
// dessin et le dépôt partagent. Aucune table ne tourne : la géométrie ne
// porte aucun angle.
/**
* @typedef {Object} GeometrieTable en cm
* @property {number} id
* @property {number} numero
* @property {'ronde'|'carree'} forme
* @property {{x: number, y: number}} centre
* @property {number} demiTaille rayon du plateau (ronde) ou demi-côté (carrée)
* @property {number} pas distance entre deux sièges voisins, au bord du plateau
* @property {Array<{siege: number, x: number, y: number, rayon: number}>} chaises sièges 1 à places
* @property {{x: number, y: number}} poignee centre du disque de la poignée
* @property {number} rayonEmprise demiTaille + 2 × RAYON_CHAISE : rayon du disque d'emprise
* (ronde) ou demi-côté du carré d'emprise (carrée)
* @property {{x: number, y: number}} origineListe coin haut gauche du bloc de noms
*/
/** Diamètre minimal d'une table ronde, côté minimal d'une carrée, en cm :
* réglage par défaut, non norme (§ 7.7). */
export const PLANCHER_DIAMETRE = 70;
/** Longueur de bord par convive, en cm (§ 7.7). */
export const LARGEUR_PAR_PLACE = 60;
/** Rayon d'une chaise, en cm. */
export const RAYON_CHAISE = 22;
/** Rayon du disque de la poignée, en cm. */
export const RAYON_POIGNEE = 15;
/** Distance entre l'emprise et le bord de la poignée, en cm. */
export const ECART_POIGNEE = 8;
/** Distance entre l'emprise et le bloc de noms, en cm. */
export const ECART_LISTE = 10;
/** Distance entre deux cases de la grille des tables créées, en cm. */
export const PAS_GRILLE = 300;
// Accroissement du rayon d'une ronde pour une place de plus, au-dessus du
// plancher : le périmètre croît de LARGEUR_PAR_PLACE, le rayon de 60 / 2π.
const RAYON_PAR_PLACE = LARGEUR_PAR_PLACE / (2 * Math.PI);
// Les quatre côtés d'une carrée, dans l'ordre où ils reçoivent les places —
// haut, droit, bas, gauche — : le coin où commence le côté dans le sens
// horaire, en demi-côtés depuis le centre, la direction de parcours et la
// normale sortante.
const COTES = [
{ depart: { x: -1, y: -1 }, direction: { x: 1, y: 0 }, normale: { x: 0, y: -1 } },
{ depart: { x: 1, y: -1 }, direction: { x: 0, y: 1 }, normale: { x: 1, y: 0 } },
{ depart: { x: 1, y: 1 }, direction: { x: -1, y: 0 }, normale: { x: 0, y: 1 } },
{ depart: { x: -1, y: 1 }, direction: { x: 0, y: -1 }, normale: { x: -1, y: 0 } },
];
// Lève RangeError sur un nombre de places qui n'est pas un entier ≥ 1, ou un
// diamètre ou côté saisi qui n'est pas un nombre fini > 0 : la géométrie
// d'une table se calcule sans valeur de repli.
function exigerPlaces(places, saisi) {
if (!Number.isInteger(places) || places < 1) {
throw new RangeError(`nombre de places entier ≥ 1 attendu, reçu ${JSON.stringify(places)}`);
}
if (saisi !== null && !(Number.isFinite(saisi) && saisi > 0)) {
throw new RangeError(`dimension saisie > 0 attendue, reçu ${JSON.stringify(saisi)}`);
}
}
/**
* Diamètre d'une table ronde (§ 7.7) : max(70, places × 60 / π), ou le
* diamètre saisi, qui l'emporte.
*
* @param {number} places entier ≥ 1
* @param {{saisi?: number|null}} [options]
* @returns {number} cm
*/
export function diametre(places, { saisi = null } = {}) {
exigerPlaces(places, saisi);
return saisi ?? Math.max(PLANCHER_DIAMETRE, (places * LARGEUR_PAR_PLACE) / Math.PI);
}
/**
* Côté d'une table carrée : max(70, ⌈places / 4⌉ × 60) — le côté le plus
* garni porte ⌈places / 4⌉ sièges —, ou le côté saisi, qui l'emporte.
*
* @param {number} places entier ≥ 1
* @param {{saisi?: number|null}} [options]
* @returns {number} cm
*/
export function cote(places, { saisi = null } = {}) {
exigerPlaces(places, saisi);
return saisi ?? Math.max(PLANCHER_DIAMETRE, Math.ceil(places / 4) * LARGEUR_PAR_PLACE);
}
// Chaises d'une ronde : sur le cercle de rayon demiTaille + RAYON_CHAISE,
// siège s à l'angle −π/2 + 2π(s − 1)/places, le siège 1 en haut et la
// numérotation dans le sens horaire à l'écran (y vers le bas).
function chaisesRonde(centre, demiTaille, places) {
const rayon = demiTaille + RAYON_CHAISE;
return Array.from({ length: places }, (_, i) => {
const angle = -Math.PI / 2 + (2 * Math.PI * i) / places;
return { siege: i + 1, x: centre.x + rayon * Math.cos(angle), y: centre.y + rayon * Math.sin(angle), rayon: RAYON_CHAISE };
});
}
// Nombre de sièges de chaque côté d'une carrée, dans l'ordre de COTES :
// ⌊places / 4⌋ chacun, et une place en surplus à chacun des premiers côtés.
const siegesParCote = (places) => COTES.map((_, i) => Math.floor(places / 4) + (i < places % 4 ? 1 : 0));
// Chaises d'une carrée : sur chaque côté, n sièges espacés de côté / n, le
// premier à côté / 2n du coin de départ, leur centre à RAYON_CHAISE au-delà
// du bord ; le siège 1 est le premier du côté haut.
function chaisesCarree(centre, demiTaille, places) {
const chaises = [];
siegesParCote(places).forEach((nombre, i) => {
const { depart, direction, normale } = COTES[i];
for (let j = 0; j < nombre; j += 1) {
const avance = ((j + 0.5) * 2 * demiTaille) / nombre;
chaises.push({
siege: chaises.length + 1,
x: centre.x + depart.x * demiTaille + direction.x * avance + normale.x * RAYON_CHAISE,
y: centre.y + depart.y * demiTaille + direction.y * avance + normale.y * RAYON_CHAISE,
rayon: RAYON_CHAISE,
});
}
});
return chaises;
}
/**
* Géométrie d'une table, en cm (contrat de données, GeometrieTable).
*
* Le pas d'une ronde est π × diamètre / places, calculé sous la forme
* max(60, 70π / places) qui lui est égale : elle rend 60 exactement dès que
* le périmètre borne la table, là où le produit rend 59,999… pour certains
* nombres de places et ferait basculer le régime des cibles (designation.js)
* sous le seuil. Le pas d'une carrée est son côté divisé par le nombre de
* sièges du côté le plus garni.
*
* La poignée est un disque de rayon RAYON_POIGNEE sur la diagonale à +45°
* (vers le bas à droite), son bord à ECART_POIGNEE de l'emprise : son centre
* est à rayonEmprise + 23 cm du centre d'une ronde, à rayonEmprise × √2 + 23
* cm de celui d'une carrée, au-delà du coin de son emprise.
*
* @param {{id: number, numero: number, forme: 'ronde'|'carree', position: {x: number, y: number}}} table
* l'enregistrement de charge.tables ; position est le centre
* @param {number} places la capacité courante, entier ≥ 1
* @returns {GeometrieTable}
* @throws {RangeError} pour une forme inconnue ou un nombre de places invalide
*/
export function geometrieTable(table, places) {
const { id, numero, forme } = table;
if (forme !== 'ronde' && forme !== 'carree') {
throw new RangeError(`forme de table inconnue : ${JSON.stringify(forme)}`);
}
const centre = { x: table.position.x, y: table.position.y };
const ronde = forme === 'ronde';
const demiTaille = (ronde ? diametre(places) : cote(places)) / 2;
const pas = ronde
? Math.max(LARGEUR_PAR_PLACE, (PLANCHER_DIAMETRE * Math.PI) / places)
: (2 * demiTaille) / Math.ceil(places / 4);
const chaises = ronde ? chaisesRonde(centre, demiTaille, places) : chaisesCarree(centre, demiTaille, places);
const rayonEmprise = demiTaille + 2 * RAYON_CHAISE;
const distancePoignee = (ronde ? rayonEmprise : rayonEmprise * Math.SQRT2) + ECART_POIGNEE + RAYON_POIGNEE;
const decalage = distancePoignee / Math.SQRT2;
return {
id,
numero,
forme,
centre,
demiTaille,
pas,
chaises,
poignee: { x: centre.x + decalage, y: centre.y + decalage },
rayonEmprise,
origineListe: { x: centre.x + rayonEmprise + ECART_LISTE, y: centre.y - demiTaille },
};
}
/**
* Rectangle englobant des emprises : pour chaque table, le carré de
* demi-côté rayonEmprise centré sur elle, qui circonscrit le disque d'une
* ronde et égale l'emprise d'une carrée.
*
* @param {GeometrieTable[]} geometries
* @returns {{minX: number, minY: number, maxX: number, maxY: number}|null} null sans table
*/
export function englobant(geometries) {
if (geometries.length === 0) return null;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const { centre, rayonEmprise } of geometries) {
minX = Math.min(minX, centre.x - rayonEmprise);
minY = Math.min(minY, centre.y - rayonEmprise);
maxX = Math.max(maxX, centre.x + rayonEmprise);
maxY = Math.max(maxY, centre.y + rayonEmprise);
}
return { minX, minY, maxX, maxY };
}
/**
* Position d'une table créée : la première case libre, ligne par ligne, d'une
* grille de PAS_GRILLE cm à ⌈√(T + 1)⌉ colonnes, T = centres.length, partant
* de (0, 0) ; une case est prise quand un centre est à PAS_GRILLE / 2 ou
* moins. centres : les positions de charge.tables, que l'appelant passe sans
* construire de géométrie.
*
* La recherche s'arrête : un centre est à PAS_GRILLE / 2 ou moins d'au plus
* deux cases, le plus petit disque qui contient trois cases ayant un rayon
* d'au moins PAS_GRILLE / √2 ; l'une des 2T + 1 premières cases est donc
* libre.
*
* @param {Array<{x: number, y: number}>} centres
* @returns {{x: number, y: number}}
*/
export function caseLibre(centres) {
const colonnes = Math.ceil(Math.sqrt(centres.length + 1));
for (let rang = 0; ; rang += 1) {
const candidate = { x: (rang % colonnes) * PAS_GRILLE, y: Math.floor(rang / colonnes) * PAS_GRILLE };
const prise = centres.some(({ x, y }) => Math.hypot(x - candidate.x, y - candidate.y) <= PAS_GRILLE / 2);
if (!prise) return candidate;
}
}
/**
* Nombre de places sous la poignée : placesInitiales + round(ecartRadial ×
* 2π / 60), jamais sous 2. L'écart se compte depuis le début du geste
* (§ 7.2) ; un demi-pas exact s'arrondit vers le haut (Math.round).
*
* @param {number} placesInitiales les places au début du geste
* @param {number} ecartRadial cm, projeté sur l'axe de la poignée, positif en s'éloignant du centre
* @returns {number} entier ≥ 2
*/
export function placesSousPoignee(placesInitiales, ecartRadial) {
return Math.max(2, placesInitiales + Math.round(ecartRadial / RAYON_PAR_PLACE));
}

View file

@ -0,0 +1,331 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la géométrie des tables (§ 7.1, § 7.7) : diamètre et côté tirés
// du nombre de places, chaises posées dans le sens horaire depuis le haut,
// emprise, poignée hors de toute chaise, bloc de noms, englobant, case libre
// d'une table créée et nombre de places sous la poignée. Les valeurs
// attendues se calculent à la main dans le commentaire qui les précède.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { surLeSol } from './designation.js';
import {
ECART_LISTE,
ECART_POIGNEE,
LARGEUR_PAR_PLACE,
PAS_GRILLE,
PLANCHER_DIAMETRE,
RAYON_CHAISE,
RAYON_POIGNEE,
caseLibre,
cote,
diametre,
englobant,
geometrieTable,
placesSousPoignee,
} from './tables.js';
const EPSILON = 1e-9;
// Affirme |reel − attendu| ≤ EPSILON, le libellé nommant la grandeur.
function proche(reel, attendu, libelle) {
assert.ok(Math.abs(reel - attendu) <= EPSILON, `${libelle} : ${reel} au lieu de ${attendu}`);
}
function procheDuPoint(reel, attendu, libelle) {
proche(reel.x, attendu.x, `${libelle}, x`);
proche(reel.y, attendu.y, `${libelle}, y`);
}
const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
// Une table du fichier d'état : identifiant, numéro, forme et centre.
const table = (id, forme, x, y) => ({ id, numero: id, sieges: null, forme, position: { x, y } });
describe('constantes', () => {
test('les valeurs du contrat', () => {
assert.equal(PLANCHER_DIAMETRE, 70);
assert.equal(LARGEUR_PAR_PLACE, 60);
assert.equal(RAYON_CHAISE, 22);
assert.equal(RAYON_POIGNEE, 15);
assert.equal(ECART_POIGNEE, 8);
assert.equal(ECART_LISTE, 10);
assert.equal(PAS_GRILLE, 300);
});
});
describe('diametre (§ 7.7)', () => {
test('le plancher de 70 cm borne 2 et 3 places, le périmètre borne dès 4', () => {
assert.equal(diametre(2), 70);
assert.equal(diametre(3), 70);
// 4 × 60 / π = 76,394 > 70 : un plancher appliqué jusqu'à 6 places rendrait 70.
proche(diametre(4), 240 / Math.PI, 'diamètre de 4');
proche(diametre(5), 300 / Math.PI, 'diamètre de 5');
});
test('115, 153 et 191 cm pour 6, 8 et 10 places, arrondis (§ 7.7)', () => {
assert.equal(Math.round(diametre(6)), 115);
assert.equal(Math.round(diametre(8)), 153);
assert.equal(Math.round(diametre(10)), 191);
});
test('un diamètre saisi l’emporte', () => {
assert.equal(diametre(8, { saisi: 180 }), 180);
assert.equal(diametre(2, { saisi: 50 }), 50);
});
});
describe('cote', () => {
test('max(70, ⌈places / 4⌉ × 60)', () => {
assert.equal(cote(3), 70);
assert.equal(cote(5), 120);
assert.equal(cote(8), 120);
assert.equal(cote(9), 180);
});
test('un côté saisi l’emporte', () => {
assert.equal(cote(8, { saisi: 150 }), 150);
});
});
describe('geometrieTable, ronde', () => {
// Ronde de 8 au centre (300, 600) : diamètre 480/π, demi-taille 240/π
// (≈ 76,394), chaises sur le cercle de rayon 240/π + 22 (≈ 98,394).
const g = geometrieTable(table(4, 'ronde', 300, 600), 8);
const r = 240 / Math.PI;
test('identité, forme, centre, demi-taille et pas', () => {
assert.equal(g.id, 4);
assert.equal(g.numero, 4);
assert.equal(g.forme, 'ronde');
assert.deepEqual(g.centre, { x: 300, y: 600 });
proche(g.demiTaille, r, 'demi-taille');
proche(g.pas, 60, 'pas');
});
test('huit chaises de rayon 22, siège 1 en haut, sens horaire', () => {
assert.equal(g.chaises.length, 8);
g.chaises.forEach((chaise, i) => {
assert.equal(chaise.siege, i + 1);
assert.equal(chaise.rayon, RAYON_CHAISE);
proche(distance(chaise, g.centre), r + 22, `distance du siège ${chaise.siege}`);
});
procheDuPoint(g.chaises[0], { x: 300, y: 600 - (r + 22) }, 'siège 1');
procheDuPoint(g.chaises[2], { x: 300 + r + 22, y: 600 }, 'siège 3');
procheDuPoint(g.chaises[4], { x: 300, y: 600 + r + 22 }, 'siège 5');
});
test('emprise, poignée et origine de la liste', () => {
proche(g.rayonEmprise, r + 44, 'rayon d’emprise');
const ecart = (r + 44 + 23) / Math.SQRT2;
procheDuPoint(g.poignee, { x: 300 + ecart, y: 600 + ecart }, 'poignée');
procheDuPoint(g.origineListe, { x: 300 + r + 54, y: 600 - r }, 'origine de la liste');
});
test('ronde de 3 : plancher de 70 cm, pas 70π/3', () => {
const petite = geometrieTable(table(1, 'ronde', 0, 0), 3);
proche(petite.demiTaille, 35, 'demi-taille');
proche(petite.pas, (70 * Math.PI) / 3, 'pas');
});
test('le pas vaut 60 exactement au-dessus du plancher, de 4 à 40 places', () => {
// Le produit π × (places × 60 / π) / places rend 59,999… pour 7 places :
// le régime des cibles basculerait alors un cran trop tôt.
let comptees = 0;
for (let places = 4; places <= 40; places += 1) {
assert.equal(geometrieTable(table(1, 'ronde', 0, 0), places).pas, 60, `${places} places`);
comptees += 1;
}
assert.equal(comptees, 37);
});
});
describe('geometrieTable, carrée', () => {
// Carrée de 8 au centre (300, 600) : côté 120, demi-côté 60, deux sièges
// par côté à 30 et 90 cm du coin, centres à 60 + 22 = 82 cm de l'axe.
const g = geometrieTable(table(2, 'carree', 300, 600), 8);
test('côté 120, pas 60, deux sièges par côté dans le sens horaire', () => {
assert.equal(g.forme, 'carree');
assert.equal(g.demiTaille, 60);
assert.equal(g.pas, 60);
assert.equal(g.chaises.length, 8);
const attendus = [
[270, 518], [330, 518], // haut, de gauche à droite
[382, 570], [382, 630], // droit, de haut en bas
[330, 682], [270, 682], // bas, de droite à gauche
[218, 630], [218, 570], // gauche, de bas en haut
];
g.chaises.forEach((chaise, i) => {
assert.equal(chaise.siege, i + 1);
assert.equal(chaise.rayon, RAYON_CHAISE);
procheDuPoint(chaise, { x: attendus[i][0], y: attendus[i][1] }, `siège ${i + 1}`);
});
});
test('emprise 104, poignée au-delà du coin, liste en (414, 540)', () => {
assert.equal(g.rayonEmprise, 104);
procheDuPoint(g.poignee, { x: 404 + 23 / Math.SQRT2, y: 704 + 23 / Math.SQRT2 }, 'poignée');
procheDuPoint(g.origineListe, { x: 414, y: 540 }, 'origine de la liste');
});
test('carrée de 3 : côtés garnis 1, 1, 1, 0 et pas 70', () => {
// Côté 70, demi-côté 35 : un siège au milieu du haut, du droit et du bas.
const petite = geometrieTable(table(1, 'carree', 0, 0), 3);
assert.equal(petite.pas, 70);
assert.equal(petite.chaises.length, 3);
procheDuPoint(petite.chaises[0], { x: 0, y: -57 }, 'siège 1');
procheDuPoint(petite.chaises[1], { x: 57, y: 0 }, 'siège 2');
procheDuPoint(petite.chaises[2], { x: 0, y: 57 }, 'siège 3');
});
test('carrée de 5 : le côté haut reçoit la place en surplus', () => {
// Côté 120 : 2 sièges en haut, puis 1 à droite, en bas et à gauche.
const g5 = geometrieTable(table(1, 'carree', 0, 0), 5);
assert.equal(g5.pas, 60);
const attendus = [[-30, -82], [30, -82], [82, 0], [0, 82], [-82, 0]];
assert.equal(g5.chaises.length, attendus.length);
g5.chaises.forEach((chaise, i) => procheDuPoint(chaise, { x: attendus[i][0], y: attendus[i][1] }, `siège ${i + 1}`));
});
});
describe('emprise et chaises, de 2 à 40 places', () => {
test('l’emprise contient chaque chaise, et deux chaises ne se touchent pas', () => {
let tables = 0;
for (const forme of ['ronde', 'carree']) {
for (let places = 2; places <= 40; places += 1) {
const g = geometrieTable(table(1, forme, 10, -20), places);
assert.equal(g.chaises.length, places);
proche(g.rayonEmprise, g.demiTaille + 2 * RAYON_CHAISE, `${forme} de ${places}, emprise`);
for (const chaise of g.chaises) {
const dx = Math.abs(chaise.x - g.centre.x);
const dy = Math.abs(chaise.y - g.centre.y);
const portee = forme === 'ronde' ? Math.hypot(dx, dy) : Math.max(dx, dy);
assert.ok(portee + chaise.rayon <= g.rayonEmprise + EPSILON, `${forme} de ${places}, siège ${chaise.siege}`);
}
for (let i = 0; i < places; i += 1) {
for (let j = i + 1; j < places; j += 1) {
assert.ok(distance(g.chaises[i], g.chaises[j]) >= 2 * RAYON_CHAISE, `${forme} de ${places}, sièges ${i + 1} et ${j + 1}`);
}
}
tables += 1;
}
}
assert.equal(tables, 78);
});
});
describe('la poignée ne touche aucune chaise (§ 7.1)', () => {
test('rondes de 3, 8 et 16, carrées de 3, 8, 12 et 16', () => {
const cas = [
['ronde', 3], ['ronde', 8], ['ronde', 16],
['carree', 3], ['carree', 8], ['carree', 12], ['carree', 16],
];
let eprouvees = 0;
for (const [forme, places] of cas) {
const g = geometrieTable(table(1, forme, 0, 0), places);
assert.ok(g.chaises.length > 0);
for (const chaise of g.chaises) {
assert.ok(
distance(g.poignee, chaise) > RAYON_CHAISE + RAYON_POIGNEE,
`${forme} de ${places}, siège ${chaise.siege} à ${distance(g.poignee, chaise)} cm`,
);
}
assert.equal(surLeSol(g.poignee, [g]), true, `${forme} de ${places} : centre de la poignée hors du sol`);
eprouvees += 1;
}
assert.equal(eprouvees, 7);
});
});
describe('aucune rotation (§ 7.1)', () => {
test('la géométrie ne porte aucun angle et une seule poignée', () => {
for (const forme of ['ronde', 'carree']) {
const g = geometrieTable(table(1, forme, 0, 0), 8);
assert.deepEqual(
Object.keys(g).sort(),
['centre', 'chaises', 'demiTaille', 'forme', 'id', 'numero', 'origineListe', 'pas', 'poignee', 'rayonEmprise'],
);
assert.deepEqual(Object.keys(g.poignee).sort(), ['x', 'y']);
for (const chaise of g.chaises) assert.deepEqual(Object.keys(chaise).sort(), ['rayon', 'siege', 'x', 'y']);
}
});
});
describe('geometrieTable refuse une table qu’elle ne sait pas dessiner', () => {
test('forme inconnue, places nulles ou non entières', () => {
assert.throws(() => geometrieTable(table(1, 'ovale', 0, 0), 8), RangeError);
assert.throws(() => geometrieTable(table(1, 'ronde', 0, 0), 0), RangeError);
assert.throws(() => geometrieTable(table(1, 'carree', 0, 0), 2.5), RangeError);
});
test('diametre et cote : places invalides, dimension saisie nulle ou non finie', () => {
assert.throws(() => diametre(0), RangeError);
assert.throws(() => cote(1.5), RangeError);
assert.throws(() => diametre(8, { saisi: 0 }), RangeError);
assert.throws(() => cote(8, { saisi: Number.NaN }), RangeError);
assert.throws(() => diametre(8, { saisi: Infinity }), RangeError);
});
});
describe('englobant', () => {
test('null sans table', () => {
assert.equal(englobant([]), null);
});
test('deux tables : min et max exacts de leurs emprises', () => {
// Ronde de 8 en (300, 600), emprise 240/π + 44 ; carrée de 8 en
// (−100, 50), emprise 104.
const r = 240 / Math.PI + 44;
const e = englobant([
geometrieTable(table(1, 'ronde', 300, 600), 8),
geometrieTable(table(2, 'carree', -100, 50), 8),
]);
assert.deepEqual(Object.keys(e).sort(), ['maxX', 'maxY', 'minX', 'minY']);
proche(e.minX, -204, 'minX');
proche(e.minY, -54, 'minY');
proche(e.maxX, 300 + r, 'maxX');
proche(e.maxY, 600 + r, 'maxY');
});
});
describe('caseLibre', () => {
test('sans table, l’origine', () => {
assert.deepEqual(caseLibre([]), { x: 0, y: 0 });
});
test('deux centres en (0, 0) et (300, 0) : deux colonnes, la case suivante est (0, 300)', () => {
assert.deepEqual(caseLibre([{ x: 0, y: 0 }, { x: 300, y: 0 }]), { x: 0, y: 300 });
});
test('un centre en (150, 0) prend les deux premières cases', () => {
assert.deepEqual(caseLibre([{ x: 150, y: 0 }]), { x: 0, y: 300 });
});
test('un centre à plus de PAS_GRILLE / 2 laisse la case libre', () => {
assert.deepEqual(caseLibre([{ x: 150.001, y: 0 }]), { x: 0, y: 0 });
});
test('quatre centres sur 2 × 2 : trois colonnes, la case suivante est (600, 0)', () => {
const centres = [{ x: 0, y: 0 }, { x: 300, y: 0 }, { x: 0, y: 300 }, { x: 300, y: 300 }];
assert.deepEqual(caseLibre(centres.slice(0, 3)), { x: 300, y: 300 });
assert.deepEqual(caseLibre(centres), { x: 600, y: 0 });
});
});
describe('placesSousPoignee', () => {
const PAS_RAYON = 60 / (2 * Math.PI);
test('écart nul, deux pas, dix pas en arrière, un pas', () => {
assert.equal(placesSousPoignee(8, 0), 8);
assert.equal(placesSousPoignee(8, 2 * PAS_RAYON), 10);
assert.equal(placesSousPoignee(8, -10 * PAS_RAYON), 2);
assert.equal(placesSousPoignee(3, PAS_RAYON), 4);
});
test('l’écart s’arrondit au pas le plus proche', () => {
assert.equal(placesSousPoignee(8, 0.4 * PAS_RAYON), 8);
assert.equal(placesSousPoignee(8, 0.6 * PAS_RAYON), 9);
assert.equal(placesSousPoignee(8, -0.6 * PAS_RAYON), 7);
});
});

75
src/geometrie/texte.js Normal file
View file

@ -0,0 +1,75 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le texte du plan (§ 7.4) : la troncature d'un nom dans la largeur d'un bloc
// de noms, et les ordonnées des lignes d'une liste, chaque ligne étant un
// <tspan> à décalage explicite. Aucune fonction ne lit le DOM : la largeur
// d'une chaîne vient d'une mesure injectée, getComputedTextLength au
// navigateur, sur la police rendue (§ 14.1).
/**
* Gabarit dont se mesure la hauteur de ligne d'une liste : capitales
* accentuées et jambages, jamais une chaîne moyenne (§ 7.4).
*/
export const GABARIT_HAUTEUR_LIGNE = 'Ôjgq Æ W';
/**
* Corps du texte des listes de noms, en cm dans le dessin : les étiquettes ne
* sont pas à l'échelle du mobilier (§ 7.7), et ce corps fixe leur taille dans
* le dessin, que le zoom agrandit avec le reste.
*/
export const TAILLE_TEXTE_LISTE_CM = 18;
// Vrai pour la première, ou la seconde, moitié d'une paire de substitution.
const estPremiereMoitie = (code) => code >= 0xd800 && code <= 0xdbff;
const estSecondeMoitie = (code) => code >= 0xdc00 && code <= 0xdfff;
/**
* Le texte entier s'il tient dans largeurMax ; sinon le plus long préfixe
* qui, suivi de la marque, y tient ; '' quand la marque seule n'y tient pas ;
* '' reste ''. Le résultat ne dépasse jamais largeurMax.
*
* La recherche avance par dichotomie sur le nombre d'unités de code du
* préfixe, la largeur d'un préfixe croissant avec sa longueur. Une coupe qui
* tomberait entre les deux moitiés d'une paire de substitution recule d'une
* unité : le préfixe ne finit jamais sur une moitié de lettre.
*
* @param {string} texte
* @param {number} largeurMax
* @param {function(string): number} mesurer largeur d'une chaîne, dans l'unité de largeurMax
* @param {string} marque ce qui suit un préfixe tronqué
* @returns {string}
*/
export function tronquer(texte, largeurMax, mesurer, marque) {
if (texte === '' || mesurer(texte) <= largeurMax) return texte;
if (!(mesurer(marque) <= largeurMax)) return '';
// Fin d'un préfixe de n unités, ramenée hors d'une paire de substitution.
const coupe = (n) =>
n > 0 && estPremiereMoitie(texte.charCodeAt(n - 1)) && estSecondeMoitie(texte.charCodeAt(n)) ? n - 1 : n;
const avecMarque = (n) => texte.slice(0, coupe(n)) + marque;
// Invariant : le préfixe de bas unités tient, suivi de la marque ; celui de
// haut unités ne tient pas, le texte entier, suivi de la marque, compris.
let bas = 0;
let haut = texte.length;
while (haut - bas > 1) {
const milieu = bas + Math.floor((haut - bas) / 2);
if (mesurer(avecMarque(milieu)) <= largeurMax) bas = milieu;
else haut = milieu;
}
return avecMarque(bas);
}
/**
* Ordonnées des n lignes d'une liste, la première à 0, chacune d'une hauteur
* de ligne sous la précédente. Lève RangeError sur un n qui n'est pas un
* entier ≥ 0.
* @param {number} n
* @param {number} hauteurLigne
* @returns {number[]}
*/
export function decalagesLignes(n, hauteurLigne) {
if (!Number.isInteger(n) || n < 0) {
throw new RangeError(`nombre de lignes : entier ≥ 0 attendu, reçu ${String(n)}`);
}
return Array.from({ length: n }, (_, i) => i * hauteurLigne);
}

View file

@ -0,0 +1,92 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du texte du plan (§ 7.4, § 14.1) : la troncature avec marque et
// les décalages des lignes d'une liste. La mesure est injectée : ici, une
// somme de largeurs par glyphe écrites dans l'épreuve, où le glyphe large
// coûte plus que la moyenne ; au navigateur, getComputedTextLength.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { GABARIT_HAUTEUR_LIGNE, TAILLE_TEXTE_LISTE_CM, decalagesLignes, tronquer } from './texte.js';
const MARQUE = '…';
// Largeur d'un glyphe : W 10, Æ 11, i 3, la marque 6, tout autre 6. Une
// moitié de paire de substitution isolée ne dessine rien de mesurable et
// compte 0 : une coupe au milieu d'une paire paraîtrait tenir.
const LARGEURS = new Map([['W', 10], ['Æ', 11], ['i', 3], [MARQUE, 6]]);
const MOITIE = /^[\u{D800}-\u{DFFF}]$/u;
const mesurer = (chaine) =>
[...chaine].reduce((somme, glyphe) => somme + (MOITIE.test(glyphe) ? 0 : (LARGEURS.get(glyphe) ?? 6)), 0);
// Une moitié de paire de substitution isolée, n'importe où dans la chaîne.
const MOITIE_ISOLEE = /[\u{D800}-\u{DFFF}]/u;
describe('texte : la troncature (§ 7.4, § 14.1)', () => {
test('un texte qui tient reste entier ; sinon le plus long préfixe qui, suivi de la marque, tient', () => {
assert.equal(mesurer('Ombrelle'), 48);
assert.equal(tronquer('Ombrelle', 60, mesurer, MARQUE), 'Ombrelle');
assert.equal(tronquer('Ombrelle', 48, mesurer, MARQUE), 'Ombrelle');
assert.equal(tronquer('WWWWWWWW', 60, mesurer, MARQUE), 'WWWWW…');
assert.equal(mesurer('WWWWW…'), 56);
assert.equal(tronquer('ÆÆÆÆÆÆ', 40, mesurer, MARQUE), 'ÆÆÆ…');
assert.equal(tronquer('Ombrelle', 47, mesurer, MARQUE), 'Ombrel…');
});
test("la marque seule qui ne tient pas rend '' ; un texte entier qui tient sans marque reste ; '' reste ''", () => {
assert.equal(tronquer('Ombrelle', 4, mesurer, MARQUE), '');
assert.equal(tronquer('i', 4, mesurer, MARQUE), 'i');
assert.equal(tronquer('', 0, mesurer, MARQUE), '');
assert.equal(tronquer('', 100, mesurer, MARQUE), '');
assert.equal(tronquer('Ombrelle', 6, mesurer, MARQUE), '…');
});
test("une lettre hors du plan de base n'est jamais coupée entre ses deux unités UTF-16", () => {
const texte = '\u{1D49C}\u{1D49C}\u{1D49C}\u{1D49C}';
assert.equal(texte.length, 8);
assert.equal(tronquer(texte, 20, mesurer, MARQUE), '\u{1D49C}\u{1D49C}…');
let comptes = 0;
for (let largeur = 0; largeur <= 30; largeur += 1) {
const rendu = tronquer(texte, largeur, mesurer, MARQUE);
assert.ok(!MOITIE_ISOLEE.test(rendu), `largeur ${largeur} : ${JSON.stringify(rendu)}`);
comptes += 1;
}
assert.equal(comptes, 31);
assert.equal(tronquer('Iris \u{1D49C}mbrelle', 44, mesurer, MARQUE), 'Iris \u{1D49C}…');
});
test('le résultat ne dépasse jamais la largeur, et garde le plus long préfixe admis', () => {
const textes = ['Théo Pervenche', 'WWWWWWWWWWWW', 'ÆÆÆÆ iii WWW', 'Ondine Lacasse \u{1D49C}\u{1D49C} Grisaille', 'Ôjgq', 'x'.repeat(200)];
let comptes = 0;
for (const texte of textes) {
for (let largeur = 0; largeur <= 130; largeur += 1) {
const rendu = tronquer(texte, largeur, mesurer, MARQUE);
assert.ok(mesurer(rendu) <= largeur, `${texte} dans ${largeur} : ${rendu}`);
if (rendu !== texte && rendu !== '') {
// Le préfixe d'un glyphe de plus, suivi de la marque, ne tiendrait pas.
const prefixe = [...rendu.slice(0, -MARQUE.length)];
const suivant = [...texte][prefixe.length];
assert.ok(texte.startsWith(prefixe.join('')), `${texte} dans ${largeur}`);
assert.ok(mesurer(prefixe.join('') + suivant + MARQUE) > largeur, `${texte} dans ${largeur} : ${rendu}`);
}
comptes += 1;
}
}
assert.equal(comptes, 6 * 131);
});
});
describe("texte : les lignes d'une liste (§ 7.4)", () => {
test('les ordonnées des n lignes partent de 0 et avancent de la hauteur de ligne', () => {
assert.deepEqual(decalagesLignes(3, 21.5), [0, 21.5, 43]);
assert.deepEqual(decalagesLignes(1, 21.5), [0]);
assert.deepEqual(decalagesLignes(0, 21.5), []);
assert.throws(() => decalagesLignes(-1, 21.5), RangeError);
assert.throws(() => decalagesLignes(1.5, 21.5), RangeError);
});
test('le gabarit de hauteur porte capitales accentuées et jambages ; le corps des listes vaut 18 cm', () => {
assert.equal(GABARIT_HAUTEUR_LIGNE, 'Ôjgq Æ W');
assert.equal(TAILLE_TEXTE_LISTE_CM, 18);
});
});

218
src/geometrie/vue.js Normal file
View file

@ -0,0 +1,218 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La vue du plan (§ 7.2) : trois nombres, k en px/cm, tx et ty en px CSS, que
// le dessin écrit dans un seul attribut, transform="translate(tx,ty) scale(k)",
// sur le groupe [data-vue] d'un <svg data-plan> sans viewBox. Toute
// conversion entre l'écran et le dessin passe par ce module : le dessin, la
// désignation d'un siège et le dépôt d'un glissement lisent la même formule.
// Aucune fonction ne lit le DOM : le cadre, rectangle du <svg> à l'écran, est
// un paramètre que l'appelant relit au pointerdown.
/** @typedef {{k: number, tx: number, ty: number}} Vue */
/** @typedef {{gauche: number, haut: number, largeur: number, hauteur: number}} Cadre */
/** @typedef {{x: number, y: number}} Point */
/** @typedef {{minX: number, minY: number, maxX: number, maxY: number}} Englobant */
/** Borne haute du zoom, en px/cm (§ 7.2). */
export const K_MAX = 10;
/** k_min = FACTEUR_K_MIN × k_ajusté ; aucune borne basse absolue (§ 7.2). */
export const FACTEUR_K_MIN = 0.5;
/** k ajusté d'un plan sans table, en px/cm : sans englobant, rien ne fixe d'échelle. */
export const K_PLAN_VIDE = 1;
/** Marge laissée de chaque côté du cadre par l'ajustement, en px. */
export const MARGE_AJUSTEMENT_PX = 16;
/** Facteur de zoom d'un cran de molette. */
export const FACTEUR_CRAN = 1.1;
// Longueur d'un cran de molette, par valeur de WheelEvent.deltaMode : en
// pixels, en lignes, en pages.
const CRAN_PAR_MODE = [100, 3, 1];
/**
* Point de dessin, en cm, sous le point écran (xEcran, yEcran), en px CSS de
* la fenêtre : la formule du § 7.2, cadre soustrait avant la translation, la
* division par k en dernier.
* @param {Vue} vue @param {Cadre} cadre
* @returns {Point}
*/
export function dessinDepuisEcran(vue, cadre, xEcran, yEcran) {
return { x: (xEcran - cadre.gauche - vue.tx) / vue.k, y: (yEcran - cadre.haut - vue.ty) / vue.k };
}
/**
* Point écran, en px CSS de la fenêtre, du point de dessin (x, y) en cm : la
* réciproque de dessinDepuisEcran.
* @param {Vue} vue @param {Cadre} cadre
* @returns {Point}
*/
export function ecranDepuisDessin(vue, cadre, x, y) {
return { x: cadre.gauche + vue.tx + vue.k * x, y: cadre.haut + vue.ty + vue.k * y };
}
/**
* Le k qui fait tenir l'englobant dans le cadre moins ses marges, plafonné à
* K_MAX ; K_PLAN_VIDE sans englobant. Null quand la largeur ou la hauteur du
* cadre ne dépasse pas 2 × MARGE_AJUSTEMENT_PX, plan vide compris : aucun k
* positif n'y fait rien tenir, et un cadre de taille nulle est celui d'un plan
* monté caché. Un englobant plat dans une direction n'y contraint rien, et un
* englobant réduit à un point prend K_MAX. Tout k rendu est fini et > 0.
* @param {Englobant|null} englobant @param {Cadre} cadre
* @returns {number|null}
*/
export function kAjuste(englobant, cadre) {
const utileX = cadre.largeur - 2 * MARGE_AJUSTEMENT_PX;
const utileY = cadre.hauteur - 2 * MARGE_AJUSTEMENT_PX;
if (!(utileX > 0 && utileY > 0)) return null;
if (englobant === null) return K_PLAN_VIDE;
const etendueX = englobant.maxX - englobant.minX;
const etendueY = englobant.maxY - englobant.minY;
// Une étendue nulle donne Infinity, que le plafond ramène à K_MAX.
const kX = etendueX > 0 ? utileX / etendueX : Number.POSITIVE_INFINITY;
const kY = etendueY > 0 ? utileY / etendueY : Number.POSITIVE_INFINITY;
return Math.min(K_MAX, kX, kY);
}
/**
* La vue ajustée : k = kAjuste, centre de l'englobant au centre du cadre ; un
* plan vide met l'origine du dessin au centre. Null quand kAjuste l'est :
* l'appelant garde la vue en place.
* @param {Englobant|null} englobant @param {Cadre} cadre
* @returns {Vue|null}
*/
export function ajuster(englobant, cadre) {
const k = kAjuste(englobant, cadre);
if (k === null) return null;
const cx = englobant === null ? 0 : (englobant.minX + englobant.maxX) / 2;
const cy = englobant === null ? 0 : (englobant.minY + englobant.maxY) / 2;
return { k, tx: cadre.largeur / 2 - k * cx, ty: cadre.hauteur / 2 - k * cy };
}
/**
* Bornes du zoom : kMin = FACTEUR_K_MIN × kAjuste, kMax = K_MAX. Lève
* RangeError sur un kAjuste qui n'est pas un nombre fini > 0, null compris :
* 0,5 × null vaudrait 0, et la borne basse disparaîtrait sans bruit.
* @param {number} kAjuste
* @returns {{kMin: number, kMax: number}}
*/
export function bornesZoom(kAjuste) {
if (typeof kAjuste !== 'number' || !Number.isFinite(kAjuste) || kAjuste <= 0) {
throw new RangeError(`k ajusté : nombre fini > 0 attendu, reçu ${String(kAjuste)}`);
}
return { kMin: FACTEUR_K_MIN * kAjuste, kMax: K_MAX };
}
// k ramené dans les bornes.
const dansBornes = (k, bornes) => Math.min(bornes.kMax, Math.max(bornes.kMin, k));
// La vue d'échelle k dont le point écran (xCadre, yCadre), relatif au coin du
// cadre, montre le même point de dessin que dans vue.
function versK(vue, xCadre, yCadre, k) {
const x = (xCadre - vue.tx) / vue.k;
const y = (yCadre - vue.ty) / vue.k;
return { k, tx: xCadre - k * x, ty: yCadre - k * y };
}
/**
* La vue, son k ramené dans les bornes, le point de dessin au centre du cadre
* restant au centre ; inchangée quand k y est déjà. Un cadrage mémorisé,
* restitué dans un autre cadre, y passe.
* @param {Vue} vue @param {Cadre} cadre @param {{kMin: number, kMax: number}} bornes
* @returns {Vue}
*/
export function borner(vue, cadre, bornes) {
const k = dansBornes(vue.k, bornes);
if (k === vue.k) return { k: vue.k, tx: vue.tx, ty: vue.ty };
return versK(vue, cadre.largeur / 2, cadre.hauteur / 2, k);
}
/**
* Zoom d'un facteur sous le point écran (xEcran, yEcran) : k devient
* k × facteur ramené dans les bornes, puis la translation se calcule sur ce k
* borné, de sorte que le point de dessin sous le pointeur y reste, à une borne
* comprise. Lève RangeError sur un facteur qui n'est pas un nombre fini > 0.
* @param {Vue} vue @param {Cadre} cadre @param {number} facteur
* @param {{kMin: number, kMax: number}} bornes
* @returns {Vue}
*/
export function zoomerSous(vue, cadre, xEcran, yEcran, facteur, bornes) {
if (!Number.isFinite(facteur) || facteur <= 0) {
throw new RangeError(`facteur de zoom : nombre fini > 0 attendu, reçu ${String(facteur)}`);
}
return versK(vue, xEcran - cadre.gauche, yEcran - cadre.haut, dansBornes(vue.k * facteur, bornes));
}
/**
* Position d'une table glissée, en cm : positionInitiale + (pointeur −
* pointeurInitial) / k. Le calcul se fait en écart : la table garde le
* décalage où le pointeur l'a saisie, et un mouvement nul la laisse en place.
* @param {Point} positionInitiale cm @param {Point} pointeurInitial px
* @param {Point} pointeur px @param {number} k
* @returns {Point}
*/
export function deplacer(positionInitiale, pointeurInitial, pointeur, k) {
return {
x: positionInitiale.x + (pointeur.x - pointeurInitial.x) / k,
y: positionInitiale.y + (pointeur.y - pointeurInitial.y) / k,
};
}
/**
* Vue glissée par le sol : tx, ty + (pointeur − pointeurInitial), en px ; k
* inchangé.
* @param {Vue} vueInitiale @param {Point} pointeurInitial @param {Point} pointeur
* @returns {Vue}
*/
export function deplacerVue(vueInitiale, pointeurInitial, pointeur) {
return {
k: vueInitiale.k,
tx: vueInitiale.tx + (pointeur.x - pointeurInitial.x),
ty: vueInitiale.ty + (pointeur.y - pointeurInitial.y),
};
}
/**
* Facteur de zoom d'un événement de molette : FACTEUR_CRAN puissance le
* nombre de crans, un cran valant 100 px, 3 lignes ou une page selon deltaMode
* (0, 1, 2) ; > 1 vers le haut (deltaY négatif), 1 sans mouvement. Lève
* RangeError sur un autre deltaMode.
* @param {number} deltaY @param {number} deltaMode
* @returns {number}
*/
export function facteurMolette(deltaY, deltaMode) {
const cran = CRAN_PAR_MODE[deltaMode];
if (!Number.isInteger(deltaMode) || cran === undefined) {
throw new RangeError(`deltaMode : 0, 1 ou 2 attendu, reçu ${String(deltaMode)}`);
}
return FACTEUR_CRAN ** (-deltaY / cran);
}
/**
* Valeur de l'attribut transform du groupe [data-vue] : chaque nombre écrit
* par String, que Number relit à l'identique.
* @param {Vue} vue
* @returns {string}
*/
export function transformDe(vue) {
return `translate(${String(vue.tx)},${String(vue.ty)}) scale(${String(vue.k)})`;
}
// Un nombre tel que String l'écrit : signe, chiffres, partie décimale,
// exposant.
const NOMBRE = String.raw`-?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?`;
const FORME_TRANSFORM = new RegExp(`^translate\\((${NOMBRE}),(${NOMBRE})\\) scale\\((${NOMBRE})\\)$`);
/**
* La vue que décrit un attribut transform de la forme qu'écrit transformDe.
* Lève SyntaxError sur toute autre forme, et sur un k qui n'est pas > 0.
* @param {string} texte
* @returns {Vue}
*/
export function vueDepuisTransform(texte) {
const lu = FORME_TRANSFORM.exec(texte);
const k = lu === null ? Number.NaN : Number(lu[3]);
if (lu === null || !(k > 0) || !Number.isFinite(k)) {
throw new SyntaxError(`transform de vue attendu, reçu ${JSON.stringify(texte)}`);
}
return { k, tx: Number(lu[1]), ty: Number(lu[2]) };
}

348
src/geometrie/vue.test.js Normal file
View file

@ -0,0 +1,348 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la vue du plan (§ 7.2, § 14.10) : la conversion écran ↔ dessin,
// l'ajustement, les bornes relatives du zoom, le zoom sous le curseur, le
// glissement en écart, la molette et l'attribut transform. Chaque valeur
// attendue est écrite en clair ou se déduit, à la main, de la formule du
// § 7.2 ; les formules fausses qu'une épreuve doit refuser sont écrites ici,
// et montrées rouges sur les mêmes cas.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { FLUX, creerPcg32 } from '../demo/prng.js';
import {
FACTEUR_CRAN,
FACTEUR_K_MIN,
K_MAX,
K_PLAN_VIDE,
MARGE_AJUSTEMENT_PX,
ajuster,
borner,
bornesZoom,
deplacer,
deplacerVue,
dessinDepuisEcran,
ecranDepuisDessin,
facteurMolette,
kAjuste,
transformDe,
vueDepuisTransform,
zoomerSous,
} from './vue.js';
const PRECISION = 1e-9;
// Vrai quand a et b diffèrent de moins de PRECISION, relativement à leur
// grandeur quand elle dépasse 1.
const proches = (a, b) => Math.abs(a - b) <= PRECISION * Math.max(1, Math.abs(a), Math.abs(b));
function egauxPoints(recu, attendu, message) {
assert.ok(
proches(recu.x, attendu.x) && proches(recu.y, attendu.y),
`${message} : (${recu.x}, ${recu.y}) au lieu de (${attendu.x}, ${attendu.y})`,
);
}
function egalesVues(recue, attendue, message) {
assert.ok(
proches(recue.k, attendue.k) && proches(recue.tx, attendue.tx) && proches(recue.ty, attendue.ty),
`${message} : ${JSON.stringify(recue)} au lieu de ${JSON.stringify(attendue)}`,
);
}
// Un englobant carré de côté c cm, coin haut gauche à l'origine.
const carre = (c) => ({ minX: 0, minY: 0, maxX: c, maxY: c });
describe('vue : conversion écran ↔ dessin (§ 7.2)', () => {
const VUE = { k: 2, tx: 10, ty: 20 };
const CADRE = { gauche: 100, haut: 50, largeur: 800, hauteur: 600 };
// Cas écrits à la main : [vue, cadre, point écran, point de dessin].
const CAS = [
[VUE, CADRE, { x: 150, y: 90 }, { x: 20, y: 10 }],
[{ k: 0.19, tx: -50, ty: 30 }, { gauche: 37, haut: 211, largeur: 1366, hauteur: 700 }, { x: 67, y: 279 }, { x: 80 / 0.19, y: 38 / 0.19 }],
[{ k: 10, tx: 0, ty: 0 }, { gauche: 37, haut: 211, largeur: 300, hauteur: 900 }, { x: 37, y: 211 }, { x: 0, y: 0 }],
];
// Écarts d'une conversion aux cas écrits à la main, un par cas faux.
const ecarts = (conversion) =>
CAS.filter(([vue, cadre, ecran, dessin]) => {
const recu = conversion(vue, cadre, ecran.x, ecran.y);
return !(proches(recu.x, dessin.x) && proches(recu.y, dessin.y));
});
test('le point écran (150, 90) est le point de dessin (20, 10), et réciproquement', () => {
egauxPoints(dessinDepuisEcran(VUE, CADRE, 150, 90), { x: 20, y: 10 }, 'dessin');
egauxPoints(ecranDepuisDessin(VUE, CADRE, 20, 10), { x: 150, y: 90 }, 'écran');
assert.deepEqual(ecarts(dessinDepuisEcran), []);
});
test('trois formules fausses tombent sur les mêmes cas : cadre oublié, division avant la translation, répartition dans un viewBox', () => {
const cadreOublie = (vue, cadre, x, y) => ({ x: (x - vue.tx) / vue.k, y: (y - vue.ty) / vue.k });
const diviseAvant = (vue, cadre, x, y) => ({
x: (x - cadre.gauche) / vue.k - vue.tx,
y: (y - cadre.haut) / vue.k - vue.ty,
});
// Un viewBox carré, de la largeur visible du dessin : le pointeur s'y
// répartit en proportion du cadre, juste en x, faux en y dès que le cadre
// n'est pas carré.
const viewBox = (vue, cadre, x, y) => {
const cote = cadre.largeur / vue.k;
return {
x: -vue.tx / vue.k + ((x - cadre.gauche) / cadre.largeur) * cote,
y: -vue.ty / vue.k + ((y - cadre.haut) / cadre.hauteur) * cote,
};
};
for (const [nom, faux] of [['cadre oublié', cadreOublie], ['division avant', diviseAvant], ['viewBox', viewBox]]) {
assert.ok(ecarts(faux).length > 0, nom);
}
});
test("l'aller-retour écran → dessin → écran rend le point, sur 25 points, quatre k et un cadre décalé", () => {
const cadre = { gauche: 37, haut: 211, largeur: 1366, hauteur: 700 };
let comptes = 0;
for (const k of [0.1, 0.19, 1, 10]) {
const vue = { k, tx: -123.25, ty: 47.5 };
for (let i = 0; i < 5; i += 1) {
for (let j = 0; j < 5; j += 1) {
const ecran = { x: cadre.gauche + (i * cadre.largeur) / 4, y: cadre.haut + (j * cadre.hauteur) / 4 };
const dessin = dessinDepuisEcran(vue, cadre, ecran.x, ecran.y);
egauxPoints(ecranDepuisDessin(vue, cadre, dessin.x, dessin.y), ecran, `k = ${k}, (${i}, ${j})`);
comptes += 1;
}
}
}
assert.equal(comptes, 100);
});
});
describe("vue : l'ajustement et les bornes relatives du zoom (§ 7.2, § 14.10)", () => {
test('un plan de 36 m dans 1 366 × 700 s\'ajuste sous 0,2 px/cm, et k_min vaut 0,5 × k_ajusté sur deux plans', () => {
const cadre = { gauche: 0, haut: 0, largeur: 1366, hauteur: 700 };
const grand = kAjuste(carre(3600), cadre);
assert.ok(proches(grand, (700 - 32) / 3600), `k ajusté ${grand}`);
assert.ok(grand < 0.2);
assert.equal(bornesZoom(grand).kMin, 0.5 * grand);
assert.ok(bornesZoom(grand).kMin < 0.2);
const petit = kAjuste(carre(400), cadre);
assert.ok(proches(petit, 668 / 400), `k ajusté ${petit}`);
assert.equal(bornesZoom(petit).kMin, 0.5 * petit);
assert.equal(bornesZoom(petit).kMax, 10);
assert.equal(FACTEUR_K_MIN, 0.5);
assert.equal(K_MAX, 10);
assert.equal(MARGE_AJUSTEMENT_PX, 16);
});
test('k_max vaut 10 : un zoom avant de facteur 2 depuis k = 9 rend 10', () => {
const cadre = { gauche: 0, haut: 0, largeur: 800, hauteur: 600 };
const vue = zoomerSous({ k: 9, tx: 0, ty: 0 }, cadre, 400, 300, 2, bornesZoom(1));
assert.equal(vue.k, 10);
});
test("ajuster centre l'englobant au k ajusté ; le plan vide prend 1 px/cm, l'origine au centre", () => {
const cadre = { gauche: 0, haut: 0, largeur: 1366, hauteur: 700 };
const vue = ajuster(carre(3600), cadre);
assert.ok(proches(vue.k, 668 / 3600));
egauxPoints(ecranDepuisDessin(vue, cadre, 1800, 1800), { x: 683, y: 350 }, 'centre');
const decale = { gauche: 37, haut: 211, largeur: 1366, hauteur: 700 };
egauxPoints(ecranDepuisDessin(ajuster(carre(3600), decale), decale, 1800, 1800), { x: 37 + 683, y: 211 + 350 }, 'centre, cadre décalé');
const vide = { gauche: 5, haut: 7, largeur: 800, hauteur: 600 };
assert.equal(K_PLAN_VIDE, 1);
assert.equal(kAjuste(null, vide), 1);
assert.deepEqual(ajuster(null, vide), { k: 1, tx: 400, ty: 300 });
assert.deepEqual(bornesZoom(kAjuste(null, vide)), { kMin: 0.5, kMax: 10 });
});
test("un englobant de 10 × 10 cm s'ajuste à k = 10, plafonné ; un englobant réduit à un point aussi", () => {
const cadre = { gauche: 0, haut: 0, largeur: 800, hauteur: 600 };
assert.equal(kAjuste(carre(10), cadre), 10);
assert.equal(ajuster(carre(10), cadre).k, 10);
assert.equal(kAjuste({ minX: 300, minY: 300, maxX: 300, maxY: 300 }, cadre), 10);
const ligne = ajuster({ minX: 0, minY: 300, maxX: 3000, maxY: 300 }, cadre);
assert.ok(proches(ligne.k, 768 / 3000));
egauxPoints(ecranDepuisDessin(ligne, cadre, 1500, 300), { x: 400, y: 300 }, 'centre');
});
test("un cadre nul ou plus petit que ses marges ne s'ajuste pas : null, plan vide compris ; 33 px de large suffisent", () => {
for (const [largeur, hauteur] of [[0, 600], [800, 0], [0, 0], [20, 20], [32, 600], [800, 32]]) {
const cadre = { gauche: 0, haut: 0, largeur, hauteur };
assert.equal(kAjuste(carre(3600), cadre), null, `${largeur} × ${hauteur}`);
assert.equal(kAjuste(null, cadre), null, `${largeur} × ${hauteur}, plan vide`);
assert.equal(ajuster(carre(3600), cadre), null, `${largeur} × ${hauteur}`);
assert.equal(ajuster(null, cadre), null, `${largeur} × ${hauteur}, plan vide`);
}
const etroit = { gauche: 0, haut: 0, largeur: 33, hauteur: 600 };
assert.ok(proches(kAjuste(carre(3600), etroit), 1 / 3600));
});
test('bornesZoom refuse un k ajusté qui n\'est pas un nombre fini positif', () => {
for (const k of [null, undefined, 0, -1, Number.NaN, Number.POSITIVE_INFINITY, '1']) {
assert.throws(() => bornesZoom(k), RangeError, String(k));
}
});
test('200 englobants et cadres tirés, nuls et vides compris : chaque nombre rendu est fini, chaque k > 0, ou le résultat vaut null', () => {
const GRAINE = 20260;
const alea = creerPcg32(GRAINE, FLUX.DEMO);
let nuls = 0;
let rendus = 0;
for (let i = 0; i < 200; i += 1) {
const nature = i % 5;
const cadre = {
gauche: alea.borne(2000) - 1000,
haut: alea.borne(2000) - 1000,
largeur: nature === 0 ? alea.borne(33) : alea.borne(3000),
hauteur: nature === 1 ? 0 : alea.borne(3000),
};
let englobant = null;
if (nature >= 2) {
const minX = alea.borne(20000) - 10000;
const minY = alea.borne(20000) - 10000;
const plat = nature === 2;
englobant = {
minX,
minY,
maxX: minX + (plat ? 0 : alea.borne(10000) / 7),
maxY: minY + (plat ? 0 : alea.borne(10000) / 3),
};
}
const k = kAjuste(englobant, cadre);
const vue = ajuster(englobant, cadre);
if (k === null) {
assert.equal(vue, null, `tirage ${i}`);
nuls += 1;
continue;
}
rendus += 1;
assert.ok(Number.isFinite(k) && k > 0 && k <= K_MAX, `tirage ${i} : k ${k}`);
assert.ok([vue.k, vue.tx, vue.ty].every(Number.isFinite) && vue.k > 0, `tirage ${i} : ${JSON.stringify(vue)}`);
const bornes = bornesZoom(k);
assert.ok(Number.isFinite(bornes.kMin) && bornes.kMin > 0, `tirage ${i}`);
}
assert.ok(nuls > 0 && rendus > 0, `${nuls} nuls, ${rendus} rendus`);
});
test('un cadrage hors des bornes est ramené dans elles, le centre du cadre fixe', () => {
const cadre = { gauche: 30, haut: 40, largeur: 800, hauteur: 600 };
const k = kAjuste({ minX: 0, minY: 0, maxX: 1000, maxY: 1420 }, cadre);
assert.ok(proches(k, 0.4));
const bornes = bornesZoom(k);
const memorise = { k: 0.1, tx: -25, ty: 60 };
const centre = dessinDepuisEcran(memorise, cadre, 30 + 400, 40 + 300);
const ramene = borner(memorise, cadre, bornes);
assert.ok(proches(ramene.k, 0.2), `k ${ramene.k}`);
egauxPoints(dessinDepuisEcran(ramene, cadre, 30 + 400, 40 + 300), centre, 'centre');
const trop = borner({ k: 25, tx: 3, ty: 4 }, cadre, bornes);
assert.equal(trop.k, 10);
assert.deepEqual(borner({ k: 1, tx: 3, ty: 4 }, cadre, bornes), { k: 1, tx: 3, ty: 4 });
});
});
describe('vue : le zoom sous le curseur (§ 7.2)', () => {
const CADRE = { gauche: 37, haut: 211, largeur: 800, hauteur: 600 };
const DEPART = { k: 1, tx: 50, ty: 40 };
const BORNES = { kMin: 0.2, kMax: 10 };
const POINTS = [
{ x: 37 + 400, y: 211 + 300 },
{ x: 37 + 799, y: 211 + 599 },
{ x: 37, y: 211 },
];
// Les cas où le point de dessin sous le pointeur bouge, pour une fonction
// de zoom donnée.
const fuites = (zoomer) =>
[1.1, 1 / 1.1, 100, 0.001].flatMap((facteur) =>
POINTS.filter((p) => {
const avant = dessinDepuisEcran(DEPART, CADRE, p.x, p.y);
const apres = dessinDepuisEcran(zoomer(DEPART, CADRE, p.x, p.y, facteur, BORNES), CADRE, p.x, p.y);
return !(proches(avant.x, apres.x) && proches(avant.y, apres.y));
}),
);
test('le point de dessin sous le pointeur y reste, au centre comme au coin, aux bornes comprises', () => {
assert.deepEqual(fuites(zoomerSous), []);
assert.equal(zoomerSous(DEPART, CADRE, 437, 511, 100, BORNES).k, 10);
assert.equal(zoomerSous(DEPART, CADRE, 437, 511, 0.001, BORNES).k, 0.2);
assert.ok(proches(zoomerSous(DEPART, CADRE, 437, 511, 1.1, BORNES).k, 1.1));
});
test("deux zooms faux tombent : autour de l'origine du cadre, et le bornage appliqué après coup", () => {
const borne = (k) => Math.min(BORNES.kMax, Math.max(BORNES.kMin, k));
const autourDeLOrigine = (vue, cadre, x, y, facteur, bornes) => ({ k: borne(vue.k * facteur), tx: vue.tx, ty: vue.ty });
const borneApres = (vue, cadre, x, y, facteur) => {
const k = vue.k * facteur;
const p = dessinDepuisEcran(vue, cadre, x, y);
return { k: borne(k), tx: x - cadre.gauche - k * p.x, ty: y - cadre.haut - k * p.y };
};
assert.ok(fuites(autourDeLOrigine).length > 0);
assert.ok(fuites(borneApres).length > 0);
});
test('un facteur qui n\'est pas un nombre fini positif est refusé', () => {
for (const facteur of [0, -1.1, Number.NaN, Number.POSITIVE_INFINITY]) {
assert.throws(() => zoomerSous(DEPART, CADRE, 437, 511, facteur, BORNES), RangeError, String(facteur));
}
});
});
describe('vue : le glissement se calcule en écart (§ 7.2)', () => {
test("une table saisie à 37 px de son centre suit l'écart du pointeur, divisé par k", () => {
const initiale = { x: 300, y: 300 };
const pointeurInitial = { x: 437, y: 260 };
assert.deepEqual(deplacer(initiale, pointeurInitial, { x: 477, y: 240 }, 2), { x: 320, y: 290 });
assert.deepEqual(deplacer(initiale, pointeurInitial, pointeurInitial, 2), initiale);
});
test("la mutation qui met l'origine de la table sous le pointeur tombe", () => {
// Vue où le centre de la table, (300, 300) cm, est à l'écran (400, 260) :
// le pointeur saisi en (437, 260) est à 37 px à droite du centre.
const vue = { k: 2, tx: -200, ty: -340 };
const cadre = { gauche: 0, haut: 0, largeur: 800, hauteur: 600 };
egauxPoints(ecranDepuisDessin(vue, cadre, 300, 300), { x: 400, y: 260 }, 'centre');
const sousLePointeur = (initiale, pointeurInitial, pointeur) => dessinDepuisEcran(vue, cadre, pointeur.x, pointeur.y);
const initiale = { x: 300, y: 300 };
const pointeurInitial = { x: 437, y: 260 };
assert.notDeepEqual(sousLePointeur(initiale, pointeurInitial, pointeurInitial, 2), initiale);
assert.notDeepEqual(sousLePointeur(initiale, pointeurInitial, { x: 477, y: 240 }, 2), { x: 320, y: 290 });
});
test("glisser le sol ajoute l'écart du pointeur, en px, et garde k", () => {
assert.deepEqual(deplacerVue({ k: 0.5, tx: 10, ty: -20 }, { x: 100, y: 100 }, { x: 130, y: 85 }), { k: 0.5, tx: 40, ty: -35 });
});
});
describe("vue : la molette et l'attribut transform", () => {
test('un cran vaut 100 px, 3 lignes ou une page ; vers le haut, le facteur dépasse 1', () => {
assert.equal(FACTEUR_CRAN, 1.1);
assert.ok(proches(facteurMolette(-100, 0), 1.1));
assert.ok(proches(facteurMolette(100, 0), 1 / 1.1));
assert.ok(proches(facteurMolette(-3, 1), 1.1));
assert.ok(proches(facteurMolette(1, 2), 1 / 1.1));
assert.ok(proches(facteurMolette(-250, 0), 1.1 ** 2.5));
assert.equal(facteurMolette(0, 0), 1);
assert.throws(() => facteurMolette(-100, 3), RangeError);
});
test('transformDe et vueDepuisTransform font l\'aller-retour ; une autre forme lève', () => {
const vues = [
{ k: 2, tx: 10, ty: 20 },
{ k: 1 / 3600, tx: -683.5, ty: 0.1 + 0.2 },
{ k: 10, tx: 1e-7, ty: -2.5e21 },
];
assert.equal(transformDe(vues[0]), 'translate(10,20) scale(2)');
for (const vue of vues) assert.deepEqual(vueDepuisTransform(transformDe(vue)), vue);
for (const texte of [
'',
'translate(10,20)',
'scale(2) translate(10,20)',
'translate(10 20) scale(2)',
'translate(10,20) scale(0)',
'translate(10,20) scale(-2)',
'translate(a,20) scale(2)',
'translate(10,20) scale(2) rotate(3)',
]) {
assert.throws(() => vueDepuisTransform(texte), SyntaxError, texte);
}
});
});

View file

@ -9,21 +9,22 @@
// contraste se calcule sur les couleurs calculées, qui ne sont les couleurs // 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 // 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 // é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 // visibilité, de contraste et d'opacité passent par les deux thèmes, que
// protocole de débogage de Chromium (CDP) émule prefers-color-scheme. // l'attribut data-theme de <html> choisit (§ 19.2). Le contraste est celui de
// palette.js, seule formule des WCAG du dépôt.
// //
// Le projet navigateur n'importe pas node:assert/strict : côté navigateur, // 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 // 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 // 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. // 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 { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { cdp } from 'vitest/browser';
import { flushSync, mount, unmount } from 'svelte'; import { flushSync, mount, unmount } from 'svelte';
import App from './App.svelte'; import App from './App.svelte';
import { contraste } from './palette.js';
import { VERSION } from '../version.genere.js'; import { VERSION } from '../version.genere.js';
const TRANSPARENT = 'rgba(0, 0, 0, 0)'; const TRANSPARENT = 'rgba(0, 0, 0, 0)';
const THEMES = ['light', 'dark']; const THEMES = ['clair', 'sombre'];
// Composantes [r, g, b, a] d'une couleur calculée. Chromium la rend sous la // 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 // forme « rgb(r, g, b) » ou « rgba(r, g, b, a) » quand la source est une
@ -38,21 +39,6 @@ function composantes(couleur) {
return [Number(m[1]), Number(m[2]), Number(m[3]), alpha]; 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. // L'élément, puis chacun de ses ancêtres jusqu'à la racine du document.
function ascendance(element) { function ascendance(element) {
const chaine = []; const chaine = [];
@ -89,20 +75,21 @@ function designation(element) {
return classe ? `${element.localName}.${classe}` : element.localName; return classe ? `${element.localName}.${classe}` : element.localName;
} }
// Exécute mesure() sous le thème demandé et rend sa valeur. L'émulation est // Exécute mesure() sous le thème demandé, posé par data-theme sur <html>, et
// levée ensuite, même quand mesure() échoue : le test suivant retrouve la // rend sa valeur. L'attribut retrouve ensuite sa valeur d'avant, ou son
// préférence propre du navigateur. // absence, même quand mesure() lève : le test suivant part du même document.
async function sousTheme(theme, mesure) { async function sousTheme(theme, mesure) {
await cdp().send('Emulation.setEmulatedMedia', { const racine = document.documentElement;
features: [{ name: 'prefers-color-scheme', value: theme }], const avant = racine.getAttribute('data-theme');
}); racine.setAttribute('data-theme', theme);
try { try {
expect(matchMedia('(prefers-color-scheme: dark)').matches, theme).toBe( return await mesure();
theme === 'dark',
);
return mesure();
} finally { } finally {
await cdp().send('Emulation.setEmulatedMedia', { features: [] }); if (avant === null) {
racine.removeAttribute('data-theme');
} else {
racine.setAttribute('data-theme', avant);
}
} }
} }
@ -210,8 +197,8 @@ describe("bandeau d'application", () => {
}); });
couleursParTheme.push(couleurs); couleursParTheme.push(couleurs);
} }
// Une émulation sans effet, ou un thème sombre qui ne redéfinit aucune // Un attribut que la feuille ne lit pas, ou un thème sombre qui ne
// couleur, ferait mesurer deux fois le thème clair. // redéfinit aucune couleur, ferait mesurer deux fois le thème clair.
expect( expect(
couleursParTheme[1], couleursParTheme[1],
`mêmes couleurs dans les deux thèmes : ${couleursParTheme[0].join(' ; ')}`, `mêmes couleurs dans les deux thèmes : ${couleursParTheme[0].join(' ; ')}`,

View file

@ -1,42 +1,144 @@
/* © 2026 TechnoLibre (http://www.technolibre.ca) /* © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) */ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) */
/* Jetons de l'interface : couleurs, typographie, espacements, rayons. Le thème /* Jetons de l'interface, un seul fichier (§ 13.3) : couleurs, polices,
suit prefers-color-scheme ; chaque thème redéfinit les mêmes jetons de échelle typographique, espacements, rayons, ombres. Les composants ne lisent
couleur, et les composants ne lisent que les jetons (§ 13.3). que ces jetons ; aucune couleur n'est écrite ailleurs.
Chaque couleur de texte tient au moins 4,5:1 sur le fond où elle est peinte, Deux thèmes, posés par l'attribut data-theme de <html>, que l'application
et aucune n'emploie de canal alpha : la couleur relue dans le style calculé règle (§ 19.2) : :root[data-theme='clair'] et :root[data-theme='sombre']
est alors la couleur peinte (§ 14.5). */ définissent les mêmes jetons de couleur ; sans attribut, le clair
s'applique, son bloc portant aussi le sélecteur :root. Le sélecteur du
sombre est plus spécifique que :root seul : posé, il l'emporte. La feuille
ne lit pas la préférence du système.
La palette se valide par calcul (§ 12.7, § 14.5) : marques.js nomme, pour
chaque marque porteuse d'information et chaque texte, le jeton qui la peint
et ceux sur lesquels elle se peint ; palette.js mesure 3:1 pour une marque,
4,5:1 pour un texte, dans les deux thèmes, en couleurs d'origine puis sous
les trois dichromaties. Aucun jeton de texte ou de marque n'a de canal
alpha : la couleur relue dans le style calculé est alors la couleur peinte.
La teinte de --couleur-conflit est réservée (§ 12.7) : tout autre jeton
chromatique s'en tient à 30° au moins, et aucun ne porte sa valeur.
La hachure d'une place à pourvoir peint des traits --couleur-hachure sur le
fond de la chaise, --couleur-chaise-fond : ce sont ses deux couleurs. */
:root { :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; --police-interface: system-ui, sans-serif;
/* Échelle typographique, du plus petit au plus grand. */
--taille-texte-petit: 0.8125rem;
--taille-texte-bandeau: 0.9375rem; --taille-texte-bandeau: 0.9375rem;
--taille-texte: 1rem;
--taille-titre: 1.25rem;
--taille-titre-grand: 1.5rem;
--espace-1: 0.25rem; --espace-1: 0.25rem;
--espace-2: 0.5rem; --espace-2: 0.5rem;
--espace-3: 0.75rem; --espace-3: 0.75rem;
--espace-4: 1rem; --espace-4: 1rem;
--espace-5: 1.5rem;
--espace-6: 2rem;
--rayon-1: 0.25rem; --rayon-1: 0.25rem;
--rayon-2: 0.5rem;
/* Ombres : une couleur opaque par thème, --couleur-ombre. */
--ombre-1: 0 1px 2px var(--couleur-ombre);
--ombre-2: 0 2px 6px var(--couleur-ombre);
} }
@media (prefers-color-scheme: dark) { :root,
:root { :root[data-theme='clair'] {
color-scheme: dark; color-scheme: light;
--couleur-page-fond: #121519; /* Page. */
--couleur-page-texte: #e3e6ea; --couleur-page-fond: #f6f7f9;
--couleur-bandeau-fond: #1d2c3f; --couleur-page-texte: #1c2128;
--couleur-bandeau-texte: #f2f4f7; --couleur-page-texte-secondaire: #4b5563;
--couleur-bandeau-filet: #5b7fa6; --couleur-page-filet: #c3c9d1;
} --couleur-ombre: #b4bcc6;
/* Bandeau d'application. */
--couleur-bandeau-fond: #172b47;
--couleur-bandeau-texte: #ffffff;
--couleur-bandeau-filet: #8fb3d9;
/* Cadre de mode (§ 8.4). */
--couleur-cadre-lecture: #5c6878;
--couleur-cadre-ecriture: #2e7d32;
--couleur-cadre-bloque: #7b3fa0;
/* Messages. */
--couleur-refus: #6a1b9a;
--couleur-avis: #7a5000;
/* Désignation. Le focus, la sélection, la mise en évidence et le fantôme
passent sur une chaise vide comme sur une chaise pleine : leur clarté se
tient entre le fond et le plein de la chaise, à 3:1 de chacun. */
--couleur-focus: #8269e1;
--couleur-selection: #1a66f0;
--couleur-mise-en-evidence: #00897b;
/* Plan : sol, mobilier, marques. */
--couleur-sol: #eceef1;
--couleur-table-fond: #ffffff;
--couleur-table-trait: #4a5059;
--couleur-chaise-fond: #ffffff;
--couleur-chaise-trait: #4a5059;
--couleur-chaise-pleine: #1f1f1f;
--couleur-hachure: #1f1f1f;
--couleur-marque: #767676;
--couleur-conflit: #b3261e;
--couleur-pastille-fond: #2b2f36;
--couleur-pastille-texte: #ffffff;
--couleur-poignee: #4a5059;
--couleur-gel: #1565c0;
--couleur-liste-texte: #1c2128;
--couleur-echelle: #1c2128;
--couleur-fantome: #b040f0;
}
:root[data-theme='sombre'] {
color-scheme: dark;
--couleur-page-fond: #121519;
--couleur-page-texte: #e3e6ea;
--couleur-page-texte-secondaire: #a3acb8;
--couleur-page-filet: #3a414b;
--couleur-ombre: #000000;
--couleur-bandeau-fond: #1d2c3f;
--couleur-bandeau-texte: #f2f4f7;
--couleur-bandeau-filet: #5b7fa6;
--couleur-cadre-lecture: #9aa5b4;
--couleur-cadre-ecriture: #81c784;
--couleur-cadre-bloque: #ce93d8;
--couleur-refus: #d7a6f0;
--couleur-avis: #f0c060;
--couleur-focus: #8764e1;
--couleur-selection: #4070f0;
--couleur-mise-en-evidence: #00897b;
--couleur-sol: #1a1d21;
--couleur-table-fond: #262b31;
--couleur-table-trait: #9aa3ae;
--couleur-chaise-fond: #15181c;
--couleur-chaise-trait: #b0b8c2;
--couleur-chaise-pleine: #e8e8e8;
--couleur-hachure: #ededed;
--couleur-marque: #7a7a7a;
--couleur-conflit: #ff8a80;
--couleur-pastille-fond: #e6e8eb;
--couleur-pastille-texte: #15181c;
--couleur-poignee: #c5ccd6;
--couleur-gel: #90caf9;
--couleur-liste-texte: #e3e6ea;
--couleur-echelle: #e3e6ea;
--couleur-fantome: #b040f0;
} }

View file

@ -1,37 +1,64 @@
// © 2026 TechnoLibre (http://www.technolibre.ca) // © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // 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 // L'assemblage des textes des écrans (§ 14.6). Un composant n'écrit aucune
// chaîne visible en dur : il les lit ici, seul endroit qu'une traduction // chaîne visible en dur : il les lit par la traduction, dont les tables
// remplace (§ 14.6). // d'écran sont sous libelles/, une par écran, réunies ici dans l'ordre
// alphabétique de leurs fichiers ; aucune table n'importe cet assemblage.
//
// libelleProvenance, aUnLibelle et titreAvecVersion lisent en français la
// table de la coquille : le bandeau, le titre de la fenêtre, l'essai de
// démarrage et le livrable les appellent sans connaître la traduction.
import { creerTraduction } from '../application/traduction.js';
import { TABLE as ACCUEIL } from './libelles/accueil.js';
import { TABLE as COMMUNS } from './libelles/communs.js';
import { TABLE as COQUILLE } from './libelles/coquille.js';
import { TABLE as DIAGNOSTIC } from './libelles/diagnostic.js';
import { TABLE as EVENEMENT } from './libelles/evenement.js';
import { TABLE as GENERATION } from './libelles/generation.js';
import { TABLE as IMPORT } from './libelles/import.js';
import { TABLE as PARTICIPANTS } from './libelles/participants.js';
import { TABLE as PLAN } from './libelles/plan.js';
import { TABLE as PROPOSITIONS } from './libelles/propositions.js';
import { TABLE as TABLES } from './libelles/tables.js';
const NOM_APPLICATION = 'Gestion table tournante Libre'; /** Les tables de libelles/, dans l'ordre alphabétique de leurs fichiers. */
export const TABLES_INTERFACE = Object.freeze([
// Mode de construction de Vite → provenance affichée dans le bandeau ACCUEIL,
// (§ 18.6). Une Map ne répond qu'aux clés qu'on y a posées : un mode nommé COMMUNS,
// comme une propriété héritée d'Object ne rend pas une fonction. COQUILLE,
const PROVENANCES = new Map([ DIAGNOSTIC,
['developpement', 'développement'], EVENEMENT,
['livraison', 'livraison'], GENERATION,
['documentation', 'documentation'], IMPORT,
['test', 'épreuve'], PARTICIPANTS,
PLAN,
PROPOSITIONS,
TABLES,
]); ]);
// Libellé de la provenance pour un mode de Vite. Un mode sans libellé, comme const { t } = creerTraduction(TABLES_INTERFACE);
// celui d'un « vite build » lancé sans --mode, s'affiche sous son propre nom.
export function libelleProvenance(mode) { // La clé de la provenance d'un mode de Vite (§ 18.6). La table est une Map :
return PROVENANCES.get(mode) ?? mode; // elle ne répond qu'aux clés qu'on y a posées, et un mode nommé comme une
} // propriété héritée d'Object n'en trouve aucune.
const cleProvenance = (mode) => `coquille.provenance.${mode}`;
// Vrai quand le mode a un libellé déclaré. Comparer libelleProvenance(mode) à // Vrai quand le mode a un libellé déclaré. Comparer libelleProvenance(mode) à
// mode ne le dit pas : « livraison » et « documentation » ont un libellé // mode ne le dit pas : « livraison » et « documentation » ont un libellé
// identique à leur nom. // identique à leur nom.
export function aUnLibelle(mode) { export function aUnLibelle(mode) {
return PROVENANCES.has(mode); return COQUILLE.has(cleProvenance(mode));
}
// 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 aUnLibelle(mode) ? t(cleProvenance(mode)) : mode;
} }
// Nom de l'application suivi de la version affichée : texte du bandeau et // Nom de l'application suivi de la version affichée : texte du bandeau et
// titre de la fenêtre (§ 18.6). // titre de la fenêtre (§ 18.6).
export function titreAvecVersion(version) { export function titreAvecVersion(version) {
return `${NOM_APPLICATION} — ${version}`; return t('coquille.titre', { version });
} }

View file

@ -5,10 +5,16 @@
// libellé de provenance, comme le mode test que pose Vitest ; un mode sans // 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 // libellé s'affiche sous son propre nom plutôt que de laisser le bandeau muet
// sur la provenance. // sur la provenance.
//
// Les textes des écrans se partagent en tables, une par écran, sous
// libelles/ : chacune exporte TABLE, une Map de libellés, et libelles.js les
// assemble par ordre alphabétique des fichiers.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readdirSync, readFileSync } from 'node:fs';
import { TABLES_APPLICATION } from '../application/libelles.js';
import { creerTraduction } from '../application/traduction.js';
import { describe, test } from '../../test/lanceur.js'; import { describe, test } from '../../test/lanceur.js';
import { aUnLibelle, libelleProvenance, titreAvecVersion } from './libelles.js'; import { TABLES_INTERFACE, aUnLibelle, libelleProvenance, titreAvecVersion } from './libelles.js';
// Modes nommés par les scripts de package.json, triés, sans doublon. // Modes nommés par les scripts de package.json, triés, sans doublon.
function modesDesScripts() { function modesDesScripts() {
@ -48,3 +54,54 @@ describe('libellés', () => {
assert.equal(titreAvecVersion('X'), 'Gestion table tournante Libre — X'); assert.equal(titreAvecVersion('X'), 'Gestion table tournante Libre — X');
}); });
}); });
// Les modules de table de libelles/, épreuves exceptées, par ordre
// alphabétique, et ce que chacun exporte.
async function tablesDuDossier() {
const noms = readdirSync(new URL('./libelles/', import.meta.url))
.filter((nom) => nom.endsWith('.js') && !nom.endsWith('.test.js'))
.sort();
return Promise.all(noms.map(async (nom) => ({ table: nom.slice(0, -3), module: await import(`./libelles/${nom}`) })));
}
describe('libellés : les tables des écrans (§ 14.6)', () => {
test('chaque module de libelles/ exporte TABLE, une Map ; TABLES_INTERFACE les assemble, onze, par ordre alphabétique', async () => {
const tables = await tablesDuDossier();
assert.equal(tables.length, 11);
assert.deepEqual(tables.map(({ table }) => table), [
'accueil', 'communs', 'coquille', 'diagnostic', 'evenement', 'generation',
'import', 'participants', 'plan', 'propositions', 'tables',
]);
for (const { table, module } of tables) assert.ok(module.TABLE instanceof Map, table);
assert.equal(TABLES_INTERFACE.length, tables.length);
tables.forEach(({ table, module }, rang) => assert.equal(TABLES_INTERFACE[rang], module.TABLE, table));
});
test("les tables d'écran ne portent que des libellés, chacun préfixé du nom de sa table", async () => {
const tables = await tablesDuDossier();
let comptees = 0;
for (const { table, module } of tables) {
const forme = new RegExp(`^${table}(?:\\.[a-z][A-Za-z0-9]*)+$`);
for (const cle of module.TABLE.keys()) {
comptees += 1;
assert.match(cle, forme, table);
}
}
assert.ok(comptees > 0, 'aucune clé');
});
test("les tables de l'application et des écrans se réunissent sans clé partagée", () => {
assert.ok(TABLES_APPLICATION.length > 0 && TABLES_INTERFACE.length > 0);
const { cles } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
assert.ok(cles().length > 0);
});
test("la coquille se lit en français dans coquille.js : son titre est celui de titreAvecVersion, ses provenances celles de libelleProvenance", () => {
const { t } = creerTraduction(TABLES_INTERFACE);
assert.equal(t('coquille.titre', { version: 'X' }), titreAvecVersion('X'));
assert.equal(t('coquille.nom'), 'Gestion table tournante Libre');
for (const mode of ['developpement', 'livraison', 'documentation', 'test']) {
assert.equal(t(`coquille.provenance.${mode}`), libelleProvenance(mode), mode);
}
});
});

View file

@ -0,0 +1,7 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les libellés de l'accueil : la liste des événements, les démonstrations, les
// opérations de fichiers, la corbeille, le dossier de travail et la
// récupération (§ 14.6). Chaque clé commence par « accueil. ».
export const TABLE = new Map();

View file

@ -0,0 +1,6 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les libellés communs aux écrans : commandes, confirmations, remèdes d'un
// refus (§ 14.6). Chaque clé commence par « communs. ».
export const TABLE = new Map();

View file

@ -0,0 +1,14 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les textes de la coquille (§ 18.6) : le nom de l'application, le titre du
// bandeau et de la fenêtre, et la provenance de la construction par mode de
// Vite. Un mode sans clé s'affiche sous son propre nom (libelles.js).
export const TABLE = new Map([
['coquille.nom', 'Gestion table tournante Libre'],
['coquille.titre', ({ version }, texte) => `${texte('coquille.nom')} \u{2014} ${version}`],
['coquille.provenance.developpement', 'développement'],
['coquille.provenance.livraison', 'livraison'],
['coquille.provenance.documentation', 'documentation'],
['coquille.provenance.test', 'épreuve'],
]);

View file

@ -0,0 +1,6 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les libellés du diagnostic d'une configuration (§ 14.6). Chaque clé commence
// par « diagnostic. ».
export const TABLE = new Map();

View file

@ -0,0 +1,7 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les libellés de l'événement ouvert : son en-tête, son mode, ses
// avertissements, le support rompu (§ 14.6). Chaque clé commence par «
// evenement. ».
export const TABLE = new Map();

View file

@ -0,0 +1,6 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les libellés de la génération : réglages et avancement (§ 14.6). Chaque clé
// commence par « generation. ».
export const TABLE = new Map();

View file

@ -0,0 +1,6 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les libellés de l'import d'une liste : aperçu et rapport (§ 14.6). Chaque
// clé commence par « import. ».
export const TABLE = new Map();

View file

@ -0,0 +1,6 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les libellés de la liste des participants : grille, formulaire, fiche (§
// 14.6). Chaque clé commence par « participants. ».
export const TABLE = new Map();

View file

@ -0,0 +1,6 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les libellés du plan de salle : vue, tables, places, panneaux et listes de
// travail (§ 14.6). Chaque clé commence par « plan. ».
export const TABLE = new Map();

View file

@ -0,0 +1,6 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les libellés du tableau des propositions (§ 14.6). Chaque clé commence par «
// propositions. ».
export const TABLE = new Map();

View file

@ -0,0 +1,6 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les libellés des tables et des tours (§ 14.6). Chaque clé commence par «
// tables. ».
export const TABLE = new Map();

74
src/interface/marques.js Normal file
View file

@ -0,0 +1,74 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Registre des marques porteuses d'information et des textes (§ 14.5,
// § 7.5, § 7.6, § 7.7). Chaque entrée nomme le jeton qui la peint et chaque
// jeton sur lequel elle se peint ; verifierPalette (palette.js) mesure le
// seuil de chaque couple dans les deux thèmes, sous les trois dichromaties,
// et les épreuves de rendu atteignent la marque par data-marque, qui porte
// son nom.
//
// Les remplissages d'une chaise sont son fond, son remplissage plein et les
// deux couleurs de la hachure — des traits --couleur-hachure sur le fond de
// la chaise : une marque posée sur une chaise se mesure sur chacun.
const c = (nom) => `--couleur-${nom}`;
const REMPLISSAGES_CHAISE = ['chaise-fond', 'chaise-pleine', 'hachure'];
function entree(nom, jeton, fonds, seuil) {
return Object.freeze({ nom, jeton: c(jeton), fonds: Object.freeze(fonds.map(c)), seuil });
}
const marque = (nom, jeton, fonds) => entree(nom, jeton, fonds, 3);
const texte = (nom, jeton, fonds) => entree(nom, jeton, fonds, 4.5);
/** Les marques porteuses d'information (§ 14.5) : { nom, jeton, fonds, seuil: 3 } — nom, la
* désignation data-marque ; jeton, celui qui la peint ; fonds, chaque jeton sur lequel elle
* se peint, chaque remplissage d'une chaise pour une marque qui passe sur une chaise. */
export const MARQUES = Object.freeze([
// Sur la chaise (§ 7.6) : cadenas posés sur ses remplissages ; l'anneau
// l'entoure, sur le sol et le bord du plateau qu'elle touche.
marque('cadenasFerme', 'marque', REMPLISSAGES_CHAISE),
marque('cadenasOuvert', 'marque', REMPLISSAGES_CHAISE),
marque('anneau', 'marque', ['sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
marque('hachure', 'hachure', ['chaise-fond']),
marque('remplissagePlein', 'chaise-pleine', ['sol', 'table-fond']),
// Le conflit (§ 7.5) : contour épaissi sur le sol, trame sur le plateau,
// pastille chiffrée près de la table.
marque('contourConflit', 'conflit', ['sol', 'table-fond']),
marque('trameConflit', 'conflit', ['table-fond']),
marque('pastille', 'pastille-fond', ['sol', 'table-fond']),
marque('poignee', 'poignee', ['sol']),
marque('gel', 'gel', ['sol', 'table-fond']),
// La désignation se pose sur la page, le sol, le plateau et une chaise
// vide, pourvue ou à pourvoir : chacun de ses remplissages est un fond.
marque('miseEnEvidence', 'mise-en-evidence', ['page-fond', 'sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
marque('selection', 'selection', ['page-fond', 'sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
// Le cadre de mode (§ 8.4) entoure le contenu de l'événement, sur la page.
marque('cadreLecture', 'cadre-lecture', ['page-fond']),
marque('cadreEcriture', 'cadre-ecriture', ['page-fond']),
marque('cadreBloque', 'cadre-bloque', ['page-fond']),
marque('focus', 'focus', ['page-fond', 'bandeau-fond', 'sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
// Le mobilier sur le sol : le trait de la table, celui de la chaise, qui
// touche aussi le plateau.
marque('contourTable', 'table-trait', ['sol']),
marque('contourChaise', 'chaise-trait', ['sol', 'table-fond']),
// L'échelle graphique (§ 7.7) vit dans la surface du plan, sur le sol ou la
// page ; le fantôme d'un glissement passe sur le sol, les tables et les
// chaises.
marque('echelle', 'echelle', ['sol', 'page-fond']),
marque('fantome', 'fantome', ['sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
]);
/** Les textes et leurs fonds, même forme, seuil 4,5 : page, secondaire, bandeau, refus,
* avis, listes du plan sur le sol, pastille. */
export const TEXTES = Object.freeze([
texte('page', 'page-texte', ['page-fond']),
texte('secondaire', 'page-texte-secondaire', ['page-fond']),
texte('bandeau', 'bandeau-texte', ['bandeau-fond']),
texte('refus', 'refus', ['page-fond']),
texte('avis', 'avis', ['page-fond']),
texte('listes', 'liste-texte', ['sol']),
texte('pastille', 'pastille-texte', ['pastille-fond']),
]);

View file

@ -0,0 +1,132 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le registre des marques porteuses d'information et des textes (§ 14.5,
// § 7.5, § 7.6, § 7.7), et la palette de jetons.css validée par calcul sur
// lui (§ 12.7) : chaque entrée tient son seuil sur chacun de ses fonds, dans
// les deux thèmes, en couleurs d'origine puis sous les trois dichromaties.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from '../../test/lanceur.js';
import { MARQUES, TEXTES } from './marques.js';
import { lireBlocs, lireJetons, verifierPalette } from './palette.js';
const JETONS = readFileSync(new URL('./jetons.css', import.meta.url), 'utf8');
const THEMES = ['clair', 'sombre'];
const NOMS_MARQUES = [
'cadenasFerme', 'cadenasOuvert', 'anneau', 'hachure', 'remplissagePlein',
'contourConflit', 'trameConflit', 'pastille', 'poignee', 'gel', 'miseEnEvidence',
'selection', 'cadreLecture', 'cadreEcriture', 'cadreBloque', 'focus',
'contourTable', 'contourChaise', 'echelle', 'fantome',
];
// Les remplissages d'une chaise : fond, pleine, et les deux couleurs de la
// hachure — ses traits sur le fond de la chaise.
const REMPLISSAGES_CHAISE = ['--couleur-chaise-fond', '--couleur-chaise-pleine', '--couleur-hachure'];
// Fonds minimaux des marques de désignation et du fantôme. « chaise » vaut
// chacun des remplissages d'une chaise : la marque se pose sur une chaise
// vide, pourvue ou à pourvoir.
const c = (nom) => `--couleur-${nom}`;
const FONDS_REQUIS = {
focus: [...['page-fond', 'bandeau-fond', 'sol', 'table-fond'].map(c), ...REMPLISSAGES_CHAISE],
selection: [...['page-fond', 'sol', 'table-fond'].map(c), ...REMPLISSAGES_CHAISE],
miseEnEvidence: [...['page-fond', 'sol', 'table-fond'].map(c), ...REMPLISSAGES_CHAISE],
fantome: [...['sol', 'table-fond'].map(c), ...REMPLISSAGES_CHAISE],
};
const parNom = (registre, nom) => registre.find((e) => e.nom === nom);
describe('registre des marques', () => {
test('les vingt marques du contrat, noms uniques, seuil 3, au moins un fond chacune', () => {
assert.equal(MARQUES.length, NOMS_MARQUES.length);
assert.deepEqual(MARQUES.map((m) => m.nom), NOMS_MARQUES);
assert.equal(new Set(MARQUES.map((m) => m.nom)).size, MARQUES.length);
for (const marque of MARQUES) {
assert.deepEqual(Object.keys(marque).sort(), ['fonds', 'jeton', 'nom', 'seuil'], marque.nom);
assert.equal(marque.seuil, 3, marque.nom);
assert.ok(marque.fonds.length > 0, marque.nom);
assert.equal(new Set(marque.fonds).size, marque.fonds.length, `${marque.nom} : fond en double`);
}
});
test('le registre est figé', () => {
assert.ok(Object.isFrozen(MARQUES) && Object.isFrozen(TEXTES));
assert.ok(MARQUES.every((m) => Object.isFrozen(m) && Object.isFrozen(m.fonds)));
assert.ok(TEXTES.every((m) => Object.isFrozen(m) && Object.isFrozen(m.fonds)));
});
test('le focus, la sélection, la mise en évidence et le fantôme se mesurent sur chaque fond où ils se peignent, chaque remplissage de chaise compris', () => {
for (const nom of ['focus', 'selection', 'miseEnEvidence', 'fantome']) {
const marque = parNom(MARQUES, nom);
assert.notEqual(marque, undefined, nom);
for (const fond of FONDS_REQUIS[nom]) {
assert.ok(marque.fonds.includes(fond), `${nom} : ${fond}`);
}
}
});
test('une marque posée sur une chaise se mesure sur chacun de ses remplissages', () => {
const surChaise = ['cadenasFerme', 'cadenasOuvert', 'anneau'];
for (const nom of surChaise) {
for (const fond of REMPLISSAGES_CHAISE) {
assert.ok(parNom(MARQUES, nom).fonds.includes(fond), `${nom} : ${fond}`);
}
}
assert.deepEqual(parNom(MARQUES, 'hachure').fonds, ['--couleur-chaise-fond']);
});
test('chaque jeton et chaque fond du registre existe dans les deux thèmes', () => {
const entrees = [...MARQUES, ...TEXTES];
assert.ok(entrees.length > 20);
for (const theme of THEMES) {
const jetons = lireJetons(JETONS, theme);
for (const entree of entrees) {
for (const nom of [entree.jeton, ...entree.fonds]) {
assert.ok(jetons.has(nom), `${theme} : ${entree.nom} → ${nom}`);
}
}
}
});
});
describe('registre des textes', () => {
test('page, secondaire, bandeau, refus, avis, listes du plan, pastille ; seuil 4,5', () => {
assert.deepEqual(TEXTES.map((t) => t.nom), ['page', 'secondaire', 'bandeau', 'refus', 'avis', 'listes', 'pastille']);
assert.ok(TEXTES.every((t) => t.seuil === 4.5 && t.fonds.length > 0));
assert.deepEqual(parNom(TEXTES, 'listes').fonds, ['--couleur-sol']);
assert.deepEqual(parNom(TEXTES, 'pastille').fonds, ['--couleur-pastille-fond']);
});
});
describe('palette de jetons.css validée par calcul (§ 12.7, § 14.5)', () => {
test('aucun jeton de texte ou de marque, ni aucun de leurs fonds, n’a de canal alpha', () => {
const noms = new Set([...MARQUES, ...TEXTES].flatMap((e) => [e.jeton, ...e.fonds]));
assert.ok(noms.size > 20);
const blocs = lireBlocs(JETONS);
for (const theme of THEMES) {
const selecteur = `:root[data-theme='${theme}']`;
const brutes = new Map(
blocs.filter((b) => b.selecteurs.includes(selecteur)).flatMap((b) => [...b.declarations]),
);
const jetons = lireJetons(JETONS, theme);
for (const nom of noms) {
assert.equal(jetons.get(nom)[3], 1, `${theme} : ${nom}`);
assert.doesNotMatch(
brutes.get(nom),
/rgba|hsla|transparent|#[0-9a-f]{8}\b|#[0-9a-f]{4}\b|\/\s*[\d.]+%?\s*\)/i,
`${theme} : ${nom}`,
);
}
}
});
test('chaque marque tient 3:1 sur chacun de ses fonds, dans les deux thèmes, sous les trois simulations', () => {
assert.deepEqual(verifierPalette(JETONS, MARQUES), []);
});
test('chaque texte tient 4,5:1 sur chacun de ses fonds, dans les deux thèmes, sous les trois simulations', () => {
assert.deepEqual(verifierPalette(JETONS, TEXTES), []);
});
});

292
src/interface/palette.js Normal file
View file

@ -0,0 +1,292 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La palette se valide par calcul (§ 12.7, § 14.5). Le module lit les jetons
// de couleur dans le texte de jetons.css, simule les trois dichromaties,
// mesure le contraste au sens des WCAG et contrôle la teinte réservée au
// conflit. Il est pur : il reçoit le texte de la feuille, ne lit aucun
// fichier et ne touche pas le document.
//
// Une couleur est un tableau [r, g, b, a] : composantes sRGB de 0 à 255,
// alpha de 0 à 1.
const THEMES = ['clair', 'sombre'];
// Ordre des simulations dans les fautes rendues : il ne dépend pas de l'ordre
// des clés de SIMULATIONS.
const ORDRE_SIMULATIONS = ['protanopie', 'deuteranopie', 'tritanopie'];
// Une couleur se lit chromatique quand sa plus grande et sa plus petite
// composante s'écartent d'au moins SEUIL_CHROMATIQUE sur 255 ; en deçà, elle
// se lit grise et sa teinte n'a pas de sens. Deux teintes nommées voisines,
// le rouge et l'orange, se tiennent à ECART_TEINTE degrés.
const SEUIL_CHROMATIQUE = 26;
const ECART_TEINTE = 30;
const PREFIXE_COULEUR = '--couleur-';
// Les seuls sélecteurs sous lesquels une couleur se déclare : :root, ce qui
// s'applique sans attribut, et un bloc par thème.
const SELECTEURS_COULEUR = [':root', ...THEMES.map((t) => `:root[data-theme='${t}']`)];
/** Matrices de Machado, Oliveira et Fernandes (2009), sévérité 1, appliquées en
* RGB linéaire. Chaque ligne somme à 1 : le blanc et le noir sont des points
* fixes, et un gris reste gris. */
export const SIMULATIONS = Object.freeze({
protanopie: Object.freeze([
Object.freeze([0.152286, 1.052583, -0.204868]),
Object.freeze([0.114503, 0.786281, 0.099216]),
Object.freeze([-0.003882, -0.048116, 1.051998]),
]),
deuteranopie: Object.freeze([
Object.freeze([0.367322, 0.860646, -0.227968]),
Object.freeze([0.280085, 0.672501, 0.047413]),
Object.freeze([-0.01182, 0.04294, 0.968881]),
]),
tritanopie: Object.freeze([
Object.freeze([1.255528, -0.076749, -0.178779]),
Object.freeze([-0.078411, 0.930809, 0.147602]),
Object.freeze([0.004733, 0.691367, 0.3039]),
]),
});
/** Les blocs de règles d'une feuille, dans leur ordre : { selecteurs, declarations },
* selecteurs la liste des sélecteurs, espaces resserrés et guillemets
* d'attribut ramenés à l'apostrophe ; declarations une Map propriété → valeur
* brute, la dernière déclaration d'une propriété l'emportant. Les commentaires
* sont ôtés. Un bloc imbriqué (@media, @supports) ou non fermé lève : la
* feuille de jetons n'en porte aucun, et un thème caché dans une règle
* conditionnelle échapperait à la lecture. */
export function lireBlocs(texteCss) {
const texte = texteCss.replace(/\/\*[\s\S]*?\*\//g, '');
const blocs = [];
let debut = 0;
for (;;) {
const ouvrant = texte.indexOf('{', debut);
if (ouvrant === -1) {
if (texte.slice(debut).includes('}')) {
throw new Error('accolade fermante sans bloc ouvert');
}
break;
}
const fermant = texte.indexOf('}', ouvrant);
if (fermant === -1) {
throw new Error(`bloc non fermé : ${texte.slice(debut, ouvrant).trim()}`);
}
const imbrique = texte.indexOf('{', ouvrant + 1);
if (imbrique !== -1 && imbrique < fermant) {
throw new Error(`bloc imbriqué non lu : ${texte.slice(debut, ouvrant).trim()}`);
}
const selecteurs = texte
.slice(debut, ouvrant)
.split(',')
.map((s) => s.trim().replace(/\s+/g, ' ').replace(/"/g, "'"));
const declarations = new Map();
for (const declaration of texte.slice(ouvrant + 1, fermant).split(';')) {
const deuxPoints = declaration.indexOf(':');
if (deuxPoints === -1) continue;
const nom = declaration.slice(0, deuxPoints).trim();
const valeur = declaration.slice(deuxPoints + 1).trim();
if (nom !== '') declarations.set(nom, valeur);
}
blocs.push({ selecteurs, declarations });
debut = fermant + 1;
}
return blocs;
}
// [r, g, b, a] d'une valeur de couleur littérale, ou null pour une forme que le
// module ne lit pas. Formes lues : #rgb, #rgba, #rrggbb, #rrggbbaa,
// rgb()/rgba() à virgules ou à espaces, alpha en nombre ou en pourcentage,
// transparent.
function couleurLitterale(valeur) {
const v = valeur.trim().toLowerCase();
if (v === 'transparent') return [0, 0, 0, 0];
const h = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(v);
if (h !== null) {
const chiffres = h[1].length <= 4 ? [...h[1]].map((c) => c + c) : h[1].match(/../g);
const [r, g, b, a = 'ff'] = chiffres;
return [...[r, g, b].map((x) => Number.parseInt(x, 16)), a === 'ff' ? 1 : Number.parseInt(a, 16) / 255];
}
const f = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*(?:[,/]\s*([\d.]+)(%?)\s*)?\)$/.exec(v);
if (f !== null) {
const alpha = f[4] === undefined ? 1 : Number(f[4]) / (f[5] === '%' ? 100 : 1);
return [Number(f[1]), Number(f[2]), Number(f[3]), alpha];
}
return null;
}
/** Couleurs d'un thème lues dans le texte de jetons.css : Map nom → [r, g, b, a],
* dans l'ordre des déclarations. Un jeton de couleur est une propriété
* --couleur-… ; il vaut une couleur littérale ou var(--couleur-…) d'un autre
* jeton du même thème. theme 'clair' ou 'sombre' : les blocs dont un sélecteur
* est :root[data-theme='<theme>'] ; theme null : les blocs dont un sélecteur
* est :root seul, ce qui s'applique sans attribut. Lève sur un thème sans
* bloc, une valeur illisible, un renvoi sans cible ou circulaire, et sur un
* bloc qui déclare une couleur sous un autre sélecteur que :root et
* :root[data-theme='clair' | 'sombre'] : dans le navigateur, il masquerait
* le jeton que la lecture mesure. */
export function lireJetons(texteCss, theme) {
const tous = lireBlocs(texteCss);
for (const bloc of tous) {
const autres = bloc.selecteurs.filter((s) => !SELECTEURS_COULEUR.includes(s));
const couleur = [...bloc.declarations.keys()].find((n) => n.startsWith(PREFIXE_COULEUR));
if (autres.length > 0 && couleur !== undefined) {
throw new Error(`couleur ${couleur} déclarée sous un sélecteur non lu : ${autres.join(', ')}`);
}
}
const selecteur = theme === null ? ':root' : `:root[data-theme='${theme}']`;
const blocs = tous.filter((b) => b.selecteurs.includes(selecteur));
if (blocs.length === 0) {
throw new Error(`aucun bloc pour le thème ${theme}`);
}
const brutes = new Map();
for (const bloc of blocs) {
for (const [nom, valeur] of bloc.declarations) {
if (nom.startsWith(PREFIXE_COULEUR)) brutes.set(nom, valeur);
}
}
const resolues = new Map();
const resoudre = (nom, chemin) => {
if (resolues.has(nom)) return resolues.get(nom);
if (chemin.includes(nom)) {
throw new Error(`renvoi circulaire : ${[...chemin, nom].join(' → ')}`);
}
if (!brutes.has(nom)) {
throw new Error(`jeton absent du thème ${theme} : ${nom}${chemin.length ? ` (lu par ${chemin.at(-1)})` : ''}`);
}
const valeur = brutes.get(nom);
const renvoi = /^var\(\s*(--[\w-]+)\s*\)$/.exec(valeur);
const couleur = renvoi !== null ? resoudre(renvoi[1], [...chemin, nom]) : couleurLitterale(valeur);
if (couleur === null) {
throw new Error(`couleur illisible, thème ${theme} : ${nom}: ${valeur}`);
}
resolues.set(nom, couleur);
return couleur;
};
const jetons = new Map();
for (const nom of brutes.keys()) {
jetons.set(nom, resoudre(nom, []));
}
return jetons;
}
const versLineaire = (c) => {
const s = c / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
const versSrgb = (l) => {
const borne = Math.min(1, Math.max(0, l));
const s = borne <= 0.0031308 ? borne * 12.92 : 1.055 * borne ** (1 / 2.4) - 0.055;
return s * 255;
};
/** La couleur que voit une personne atteinte de la dichromatie nommée : la
* matrice s'applique aux composantes linéaires, le résultat est borné à
* [0, 1] puis rendu en sRGB, sans arrondi. Alpha inchangé. */
export function simuler(couleur, nomSimulation) {
if (!Object.hasOwn(SIMULATIONS, nomSimulation)) {
throw new Error(`simulation inconnue : ${nomSimulation}`);
}
const matrice = SIMULATIONS[nomSimulation];
const lineaire = couleur.slice(0, 3).map(versLineaire);
const simulee = matrice.map((ligne) => versSrgb(ligne[0] * lineaire[0] + ligne[1] * lineaire[1] + ligne[2] * lineaire[2]));
return [...simulee, couleur[3] ?? 1];
}
function luminance(couleur) {
const [r, g, b] = couleur.map(versLineaire);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
/** Rapport de contraste des WCAG entre deux couleurs opaques, de 1 à 21,
* symétrique. La seule implémentation du dépôt (contrainte 1) :
* App.navigateur.test.js l'importe, test/navigateur/rendu.js la réexporte.
* Une couleur dont l'alpha est donné et différent de 1 lève : le ratio d'une
* couleur composée avec ce qui est dessous n'est pas celui de ses
* composantes. */
export function contraste(a, b) {
for (const couleur of [a, b]) {
if (couleur[3] !== undefined && couleur[3] !== 1) {
throw new Error(`contraste d'une couleur à canal alpha ${couleur[3]} : [${couleur.join(', ')}]`);
}
}
const la = luminance(a.slice(0, 3));
const lb = luminance(b.slice(0, 3));
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}
/** Chaque entrée tient son seuil sur chacun de ses fonds, sur les couleurs
* d'origine puis sous chaque simulation, dans les deux thèmes. entrees :
* [{jeton, fonds, seuil}], noms de jetons --couleur-…. Rend les fautes
* [{theme, simulation, jeton, fond, ratio, seuil}], simulation valant
* 'origine' pour les couleurs d'origine, dans l'ordre thème, simulation,
* entrée, fond ; [] quand tout tient. Un jeton absent d'un thème lève. */
export function verifierPalette(texteCss, entrees) {
const fautes = [];
for (const theme of THEMES) {
const jetons = lireJetons(texteCss, theme);
const lire = (nom) => {
if (!jetons.has(nom)) throw new Error(`jeton absent du thème ${theme} : ${nom}`);
return jetons.get(nom);
};
for (const simulation of ['origine', ...ORDRE_SIMULATIONS]) {
const vue = (c) => (simulation === 'origine' ? c : simuler(c, simulation));
for (const { jeton, fonds, seuil } of entrees) {
const couleur = vue(lire(jeton));
for (const fond of fonds) {
const ratio = contraste(couleur, vue(lire(fond)));
if (ratio < seuil) fautes.push({ theme, simulation, jeton, fond, ratio, seuil });
}
}
}
}
return fautes;
}
/** Teinte HSL d'une couleur en degrés, dans [0, 360), ou null quand sa plus
* grande et sa plus petite composante s'écartent de moins de
* SEUIL_CHROMATIQUE : la couleur se lit grise. */
export function teinte(couleur) {
const [r, g, b] = couleur;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const etendue = max - min;
if (etendue < SEUIL_CHROMATIQUE) return null;
let h;
if (max === r) h = ((g - b) / etendue) % 6;
else if (max === g) h = (b - r) / etendue + 2;
else h = (r - g) / etendue + 4;
const degres = h * 60;
return degres < 0 ? degres + 360 : degres;
}
/** § 12.7 : la teinte de chaque jeton chromatique se tient à ECART_TEINTE
* degrés au moins de celle du conflit, dans chaque thème. jetonsDuConflit :
* le jeton de conflit d'abord, puis ceux qui ne peignent que les marques de
* conflit, que le contrôle ne mesure pas. Rend les fautes [{theme, jeton,
* ecart}], ecart en degrés sur le cercle ; un jeton de conflit achromatique
* est lui-même une faute, d'écart null — aucune teinte n'est alors réservée. */
export function verifierTeinteReservee(texteCss, jetonsDuConflit) {
const [conflit] = jetonsDuConflit;
const fautes = [];
for (const theme of THEMES) {
const jetons = lireJetons(texteCss, theme);
if (!jetons.has(conflit)) throw new Error(`jeton absent du thème ${theme} : ${conflit}`);
const reference = teinte(jetons.get(conflit));
if (reference === null) {
fautes.push({ theme, jeton: conflit, ecart: null });
continue;
}
for (const [nom, couleur] of jetons) {
if (jetonsDuConflit.includes(nom)) continue;
const h = teinte(couleur);
if (h === null) continue;
const brut = Math.abs(h - reference);
const ecart = Math.min(brut, 360 - brut);
if (ecart < ECART_TEINTE) fautes.push({ theme, jeton: nom, ecart });
}
}
return fautes;
}

View file

@ -0,0 +1,356 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Lecture des jetons, simulation des dichromaties, contraste et teinte
// réservée (§ 12.7, § 13.3, § 14.5). Les fonctions s'éprouvent d'abord sur des
// feuilles d'épreuve écrites ici, où chaque défaut est posé exprès, puis sur
// le vrai fichier de jetons : ses familles, ses deux thèmes, les jetons que
// lisent les composants, la teinte du conflit.
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, test } from '../../test/lanceur.js';
import {
SIMULATIONS,
contraste,
lireBlocs,
lireJetons,
simuler,
teinte,
verifierPalette,
verifierTeinteReservee,
} from './palette.js';
const DOSSIER = fileURLToPath(new URL('.', import.meta.url));
const JETONS = readFileSync(new URL('./jetons.css', import.meta.url), 'utf8');
const NOMS_SIMULATIONS = ['protanopie', 'deuteranopie', 'tritanopie'];
// Feuille d'épreuve : le même corps de couleurs dans les deux thèmes, le
// clair valant aussi sans attribut.
function feuille(couleurs, sombre = couleurs) {
const corps = (table) =>
Object.entries(table)
.map(([nom, valeur]) => ` ${nom}: ${valeur};`)
.join('\n');
return [
':root { --espace-1: 0.25rem; }',
`:root,\n:root[data-theme='clair'] {\n color-scheme: light;\n${corps(couleurs)}\n}`,
`:root[data-theme="sombre"] {\n color-scheme: dark;\n${corps(sombre)}\n}`,
].join('\n\n');
}
// Composantes sRGB de 0 à 255 d'une couleur #rrggbb.
function hex(valeur) {
return [1, 3, 5].map((i) => Number.parseInt(valeur.slice(i, i + 2), 16));
}
const enHex = (c) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`;
describe('lireBlocs et lireJetons', () => {
test('lit les blocs dans leur ordre, commentaires ôtés, et les couleurs de chaque thème', () => {
const css = [
"/* { un commentaire n'ouvre aucun bloc } */",
':root { --espace-1: 0.25rem; --police-interface: system-ui, sans-serif; }',
":root, :root[data-theme='clair'] {",
' --couleur-a: #fff;',
' --couleur-b: #102030;',
' --couleur-c: rgb(1, 2, 3);',
' --couleur-d: rgb(4 5 6);',
' --couleur-e: var(--couleur-b);',
' --couleur-f: rgba(7, 8, 9, 0.5);',
' --couleur-g: #10203080;',
' --couleur-h: transparent;',
'}',
':root[data-theme="sombre"] { --couleur-a: #000000; }',
].join('\n');
const blocs = lireBlocs(css);
assert.equal(blocs.length, 3);
assert.deepEqual(blocs[0].selecteurs, [':root']);
assert.deepEqual(blocs[1].selecteurs, [':root', ":root[data-theme='clair']"]);
assert.deepEqual(blocs[2].selecteurs, [":root[data-theme='sombre']"]);
assert.equal(blocs[0].declarations.get('--police-interface'), 'system-ui, sans-serif');
const clair = lireJetons(css, 'clair');
assert.deepEqual([...clair.keys()], [
'--couleur-a', '--couleur-b', '--couleur-c', '--couleur-d',
'--couleur-e', '--couleur-f', '--couleur-g', '--couleur-h',
]);
assert.deepEqual(clair.get('--couleur-a'), [255, 255, 255, 1]);
assert.deepEqual(clair.get('--couleur-b'), [16, 32, 48, 1]);
assert.deepEqual(clair.get('--couleur-c'), [1, 2, 3, 1]);
assert.deepEqual(clair.get('--couleur-d'), [4, 5, 6, 1]);
assert.deepEqual(clair.get('--couleur-e'), [16, 32, 48, 1]);
assert.deepEqual(clair.get('--couleur-f'), [7, 8, 9, 0.5]);
assert.deepEqual(clair.get('--couleur-g'), [16, 32, 48, 128 / 255]);
assert.deepEqual(clair.get('--couleur-h'), [0, 0, 0, 0]);
assert.deepEqual([...lireJetons(css, 'sombre')], [['--couleur-a', [0, 0, 0, 1]]]);
// Sans attribut : les seuls blocs :root, ceux que le clair partage.
assert.deepEqual(lireJetons(css, null), clair);
});
test('lève sur un thème absent, une couleur illisible, un renvoi sans cible et un bloc imbriqué', () => {
const css = feuille({ '--couleur-a': '#ffffff' });
assert.throws(() => lireJetons(css, 'contraste'), /contraste/);
assert.throws(() => lireJetons(feuille({ '--couleur-a': 'hsl(0 0% 50%)' }), 'clair'), /--couleur-a/);
assert.throws(() => lireJetons(feuille({ '--couleur-a': 'var(--couleur-z)' }), 'clair'), /--couleur-z/);
assert.throws(
() => lireJetons(feuille({ '--couleur-a': 'var(--couleur-b)', '--couleur-b': 'var(--couleur-a)' }), 'clair'),
/--couleur-a/,
);
assert.throws(
() => lireBlocs('@media (prefers-color-scheme: dark) { :root { --couleur-a: #000; } }'),
/imbriqué/,
);
assert.throws(() => lireBlocs(':root { --couleur-a: #000;'), /fermé/);
});
test('lève sur une couleur déclarée sous un autre sélecteur que :root et les deux thèmes : elle masquerait le jeton lu', () => {
const base = feuille({ '--couleur-sol': '#eeeeee', '--couleur-focus': '#5a3fc0' });
for (const intrus of [
":root[data-theme='sombre'] svg { --couleur-sol: #ff8a80; --couleur-focus: #e8e8e8; }",
'html { --couleur-sol: #ff8a80; }',
":root[data-theme='contraste'] { --couleur-sol: #ff8a80; }",
":root, .plan { --couleur-sol: #ff8a80; }",
]) {
const css = `${base}\n${intrus}`;
for (const theme of ['clair', 'sombre', null]) {
assert.throws(() => lireJetons(css, theme), /sélecteur/, `${theme} : ${intrus}`);
}
assert.throws(() => lireJetons(`${JETONS}\n${intrus}`, 'sombre'), /sélecteur/, intrus);
}
// Un autre sélecteur sans couleur n'est pas lu, et ne lève pas.
assert.deepEqual(lireJetons(`${base}\n.plan { --espace-1: 1rem; }`, 'clair'), lireJetons(base, 'clair'));
});
});
describe('contraste et simulations', () => {
test('noir sur blanc vaut 21, une couleur sur elle-même 1, dans les deux sens', () => {
const noir = [0, 0, 0, 1];
const blanc = [255, 255, 255, 1];
assert.ok(Math.abs(contraste(noir, blanc) - 21) < 1e-9);
assert.equal(contraste(blanc, noir), contraste(noir, blanc));
assert.equal(contraste([90, 120, 30, 1], [90, 120, 30, 1]), 1);
// Valeur connue : #767676 sur blanc, 4,54:1 au sens des WCAG.
assert.ok(Math.abs(contraste(hex('#767676'), hex('#ffffff')) - 4.54) < 0.01);
});
test("lève sur une couleur à canal alpha partiel : le ratio d'une couleur composée mentirait", () => {
assert.throws(() => contraste([0, 0, 0, 0.5], [255, 255, 255, 1]), /alpha/);
assert.throws(() => contraste([0, 0, 0, 1], [255, 255, 255, 0]), /alpha/);
assert.ok(contraste([0, 0, 0], [255, 255, 255]) > 20);
});
test('les trois matrices sont celles de Machado, Oliveira et Fernandes, sévérité 1', () => {
assert.deepEqual(Object.keys(SIMULATIONS).sort(), [...NOMS_SIMULATIONS].sort());
assert.deepEqual(SIMULATIONS.protanopie[0], [0.152286, 1.052583, -0.204868]);
assert.deepEqual(SIMULATIONS.deuteranopie[1], [0.280085, 0.672501, 0.047413]);
assert.deepEqual(SIMULATIONS.tritanopie[2], [0.004733, 0.691367, 0.3039]);
});
test('le blanc et le noir sont des points fixes des trois matrices, à 1/255 près ; alpha intact', () => {
assert.equal(NOMS_SIMULATIONS.length, 3);
for (const nom of NOMS_SIMULATIONS) {
for (const couleur of [[255, 255, 255, 1], [0, 0, 0, 1]]) {
const simulee = simuler(couleur, nom);
for (let i = 0; i < 3; i += 1) {
assert.ok(Math.abs(simulee[i] - couleur[i]) <= 1, `${nom} ${couleur} → ${simulee}`);
}
assert.equal(simulee[3], 1);
}
}
assert.throws(() => simuler([0, 0, 0, 1], 'achromatopsie'), /achromatopsie/);
});
test('un gris reste gris, un rouge pur perd sa clarté sous la protanopie', () => {
const gris = simuler([118, 118, 118, 1], 'deuteranopie');
assert.ok(Math.abs(gris[0] - 118) <= 1 && Math.abs(gris[2] - 118) <= 1, `${gris}`);
const rouge = [255, 0, 0, 1];
assert.ok(contraste(simuler(rouge, 'protanopie'), [0, 0, 0, 1]) < contraste(rouge, [0, 0, 0, 1]));
});
});
describe('verifierPalette', () => {
// Cherche un rouge et un vert qui tiennent 3:1 en couleurs d'origine et
// tombent sous 3:1 sous au moins une simulation.
function coupleRougeVert() {
for (let a = 0; a <= 200; a += 8) {
for (let v = 40; v <= 200; v += 8) {
const rouge = [255, a, a, 1];
const vert = [0, v, 0, 1];
if (contraste(rouge, vert) < 3) continue;
const simule = NOMS_SIMULATIONS.some(
(nom) => contraste(simuler(rouge, nom), simuler(vert, nom)) < 3,
);
if (simule) return { rouge, vert };
}
}
return null;
}
test('un couple rouge-vert qui tient 3:1 en origine et tombe une fois simulé est refusé, sous simulation seulement', () => {
const couple = coupleRougeVert();
assert.notEqual(couple, null);
const css = feuille({ '--couleur-rouge': enHex(couple.rouge.slice(0, 3)), '--couleur-vert': enHex(couple.vert.slice(0, 3)) });
const fautes = verifierPalette(css, [{ jeton: '--couleur-rouge', fonds: ['--couleur-vert'], seuil: 3 }]);
assert.ok(fautes.length > 0);
assert.ok(fautes.every((f) => f.simulation !== 'origine'), JSON.stringify(fautes));
assert.ok(fautes.every((f) => f.ratio < 3 && f.seuil === 3));
assert.ok(fautes.every((f) => f.jeton === '--couleur-rouge' && f.fond === '--couleur-vert'));
assert.deepEqual([...new Set(fautes.map((f) => f.theme))], ['clair', 'sombre']);
});
test('une marque qui tient sur son premier fond et pas sur le second est refusée sur le second', () => {
const css = feuille({
'--couleur-marque': '#202020',
'--couleur-clair': '#ffffff',
'--couleur-sombre': '#000000',
});
const fautes = verifierPalette(css, [
{ jeton: '--couleur-marque', fonds: ['--couleur-clair', '--couleur-sombre'], seuil: 3 },
]);
// Deux thèmes, l'origine et trois simulations : huit fautes, toutes sur le second fond.
assert.equal(fautes.length, 8);
assert.ok(fautes.every((f) => f.fond === '--couleur-sombre'));
assert.deepEqual(
fautes.map((f) => `${f.theme}:${f.simulation}`),
['clair', 'sombre'].flatMap((t) => ['origine', ...NOMS_SIMULATIONS].map((s) => `${t}:${s}`)),
);
});
test('rend [] quand chaque entrée tient sur chaque fond ; lève sur un jeton inconnu', () => {
const css = feuille({ '--couleur-texte': '#000000', '--couleur-fond': '#ffffff' }, { '--couleur-texte': '#ffffff', '--couleur-fond': '#000000' });
assert.deepEqual(verifierPalette(css, [{ jeton: '--couleur-texte', fonds: ['--couleur-fond'], seuil: 4.5 }]), []);
assert.throws(
() => verifierPalette(css, [{ jeton: '--couleur-texte', fonds: ['--couleur-absente'], seuil: 3 }]),
/--couleur-absente/,
);
});
});
describe('teinte et verifierTeinteReservee (§ 12.7)', () => {
test('la teinte HSL ne vaut que pour une couleur dont les composantes s’écartent de 26 au moins', () => {
assert.equal(teinte([255, 0, 0, 1]), 0);
assert.equal(teinte([0, 255, 0, 1]), 120);
assert.equal(teinte([0, 0, 255, 1]), 240);
assert.equal(teinte([255, 0, 128, 1]) > 329 && teinte([255, 0, 128, 1]) < 331, true);
assert.equal(teinte([138, 125, 125, 1]), null); // écart 13 : gris
assert.equal(teinte([125, 100, 100, 1]), null); // écart 25 : gris
assert.notEqual(teinte([126, 100, 100, 1]), null); // écart 26 : chromatique
});
test('un refus de la teinte du conflit et d’une autre clarté est refusé, valeurs pourtant distinctes', () => {
const conflit = '#c62828';
const refus = '#5a1212';
assert.notEqual(conflit, refus);
assert.ok(Math.abs(teinte(hex(conflit)) - teinte(hex(refus))) < 1);
const css = feuille({
'--couleur-conflit': conflit,
'--couleur-conflit-trame': '#e57373',
'--couleur-refus': refus,
'--couleur-gris-rose': '#8a7d7d',
'--couleur-bleu': '#1f3a5f',
});
const fautes = verifierTeinteReservee(css, ['--couleur-conflit', '--couleur-conflit-trame']);
assert.deepEqual(fautes.map((f) => `${f.theme}:${f.jeton}`), ['clair:--couleur-refus', 'sombre:--couleur-refus']);
assert.ok(fautes.every((f) => f.ecart < 30));
});
test('le jeton de conflit lui-même doit être chromatique ; une teinte à 30° passe, à 29° non ; le cercle se referme', () => {
const gris = feuille({ '--couleur-conflit': '#808080', '--couleur-bleu': '#1f3a5f' });
assert.deepEqual(verifierTeinteReservee(gris, ['--couleur-conflit']), [
{ theme: 'clair', jeton: '--couleur-conflit', ecart: null },
{ theme: 'sombre', jeton: '--couleur-conflit', ecart: null },
]);
// Conflit à 0° ; 30° et 330° passent, 29° et 331° non.
const css = feuille({
'--couleur-conflit': '#ff0000',
'--couleur-a': '#ff8000', // 30,1°
'--couleur-b': '#ff7b00', // 28,9°
'--couleur-c': '#ff0080', // 329,9°
'--couleur-d': '#ff0079', // 331,5°
});
const fautes = verifierTeinteReservee(css, ['--couleur-conflit']);
assert.deepEqual(fautes.map((f) => `${f.theme}:${f.jeton}`), [
'clair:--couleur-b', 'clair:--couleur-d', 'sombre:--couleur-b', 'sombre:--couleur-d',
]);
});
});
// Fichiers .svelte et .css de src/interface, sous-dossiers compris, triés.
function sourcesDeStyle() {
return readdirSync(DOSSIER, { recursive: true, withFileTypes: true })
.filter((e) => e.isFile() && /\.(svelte|css)$/.test(e.name))
.map((e) => join(e.parentPath, e.name))
.sort();
}
describe('jetons.css', () => {
// Lue dans chaque épreuve : une feuille illisible fait échouer chacune, sans
// empêcher les épreuves des fonctions de se lancer.
const blocs = () => lireBlocs(JETONS);
const declares = () => new Set(blocs().flatMap((b) => [...b.declarations.keys()]));
const nomsDe = (prefixe) => [...declares()].filter((n) => n.startsWith(prefixe));
test('un fichier, toutes les familles : couleurs, polices, échelle typographique, espacements, rayons, ombres (§ 13.3)', () => {
for (const prefixe of ['--couleur-', '--police-', '--taille-', '--espace-', '--rayon-', '--ombre-']) {
assert.ok(nomsDe(prefixe).length > 0, `aucun jeton ${prefixe}…`);
}
// Une échelle a trois pas au moins, de valeurs distinctes.
const tailles = new Set(
blocs().flatMap((b) => [...b.declarations].filter(([n]) => n.startsWith('--taille-')).map(([, v]) => v)),
);
assert.ok(tailles.size >= 3, `${tailles.size} tailles distinctes`);
});
test('deux thèmes posés par data-theme, de mêmes jetons de couleur ; sans attribut, le clair ; aucune préférence du système lue', () => {
const clair = lireJetons(JETONS, 'clair');
const sombre = lireJetons(JETONS, 'sombre');
assert.ok(clair.size >= 20, `${clair.size} couleurs`);
assert.deepEqual([...sombre.keys()].sort(), [...clair.keys()].sort());
assert.deepEqual(lireJetons(JETONS, null), clair);
// Le thème sombre redéfinit chaque couleur à une autre valeur que le clair,
// sauf celles que les deux thèmes partagent exprès.
const differentes = [...clair.keys()].filter((n) => String(clair.get(n)) !== String(sombre.get(n)));
assert.ok(differentes.length > clair.size / 2, `${differentes.length} couleurs changent`);
assert.equal(/prefers-color-scheme/.test(JETONS.replace(/\/\*[\s\S]*?\*\//g, '')), false);
});
test('chaque var(--…) lu par un .svelte ou un .css de src/interface est déclaré dans jetons.css', () => {
const sources = sourcesDeStyle();
assert.ok(sources.length >= 2, `${sources.length} sources de style`);
const lus = [];
for (const chemin of sources) {
for (const [, nom] of readFileSync(chemin, 'utf8').matchAll(/var\(\s*(--[\w-]+)/g)) {
lus.push({ chemin, nom });
}
}
assert.ok(lus.length >= 11, `${lus.length} lectures`);
const noms = declares();
const absents = lus.filter((l) => !noms.has(l.nom)).map((l) => `${l.chemin} ${l.nom}`);
assert.deepEqual(absents, []);
});
test("les onze jetons d'App.svelte gardent leur nom", () => {
const noms = declares();
for (const nom of [
'--couleur-page-fond', '--couleur-page-texte', '--couleur-bandeau-fond',
'--couleur-bandeau-texte', '--couleur-bandeau-filet', '--police-interface',
'--taille-texte-bandeau', '--espace-2', '--espace-3', '--espace-4', '--rayon-1',
]) {
assert.ok(noms.has(nom), nom);
}
});
test('la teinte du conflit est réservée (§ 12.7) : chromatique, sa valeur à lui seul, à 30° de tout autre jeton chromatique', () => {
for (const theme of ['clair', 'sombre']) {
const jetons = lireJetons(JETONS, theme);
const conflit = jetons.get('--couleur-conflit');
assert.notEqual(conflit, undefined, theme);
assert.notEqual(teinte(conflit), null, `${theme} : conflit achromatique`);
const memeValeur = [...jetons].filter(([n, c]) => n !== '--couleur-conflit' && String(c) === String(conflit));
assert.deepEqual(memeValeur.map(([n]) => n), [], theme);
}
assert.deepEqual(verifierTeinteReservee(JETONS, ['--couleur-conflit']), []);
});
});

66
src/moteur/conflits.js Normal file
View file

@ -0,0 +1,66 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Conflits par table (§ 7.5) : les violations des règles actives, table par
// table et tour par tour, que le plan lit sans rien recalculer. Elles se
// lisent dans parcourirTours (indicateurs.js), le passage dont mesurer tire
// ses chiffres : la somme des paires séparer d'un plan égale ses collisions
// cumulées, celle des personnes nouvelle table ses retours choisis, et les
// paires distinctes nouveaux voisins ses rencontres répétées choisies
// (§ 13.2). La pastille d'une table compte des occurrences de violation —
// une paire en collision, une paire revue, une personne revenue —, et chaque
// règle porte les siennes dans son unité (§ 5.4).
//
// « Varier les appartenances » ne se localise pas à une table : aucune
// violation n'en sort.
import { parcourirTours } from './indicateurs.js';
/**
* @typedef {Object} ConflitsTable
* @property {number} table identifiant
* @property {number} nombre violations des règles actives à ce tour, toutes règles
* @property {Array<{regle: 'separerAppartenances'|'nouveauxVoisins', paires: number[][]}
* |{regle: 'nouvelleTable', personnes: number[]}>} regles
* identifiants croissants, règles dans cet ordre ; une règle inactive
* ou sans violation est omise
*/
/**
* Violations des règles actives (§ 7.5), lues dans parcourirTours et rendues
* en identifiants : séparer les appartenances — les collègues réunis ;
* nouveaux voisins — les paires revues ; nouvelle table — les personnes
* revenues. Une paire revue se signale à chaque rencontre répétée, sauf quand
* toutes ses rencontres jusque-là ont eu lieu à une table où les deux étaient
* réservés ; une personne revenue, à chaque visite répétée d'une table, sauf
* quand elle y est réservée à ce tour et l'a été à une visite antérieure.
*
* Les index de personnes croissent avec les identifiants : les listes de
* parcourirTours, traduites terme à terme, restent croissantes.
*
* Ne modifie ni l'instance ni le plan ; lève ce que lève exigerPlanIndexe.
*
* @param {import('./types.js').Instance} instance
* @param {ArrayLike<number>} tableDe
* @returns {ConflitsTable[][]} [tour][index de table], tables dans l'ordre de l'instance
*/
export function conflitsParTable(instance, tableDe) {
const { ids, idsTables, contraintes } = instance;
const enIds = (paires) => paires.map(([a, b]) => [ids[a], ids[b]]);
return parcourirTours(instance, tableDe).parTour.map((tables) =>
tables.map(({ collegues, revues, revenus }, t) => {
const regles = [];
if (contraintes.separerAppartenances && collegues.length > 0) {
regles.push({ regle: 'separerAppartenances', paires: enIds(collegues) });
}
if (contraintes.nouveauxVoisins && revues.length > 0) {
regles.push({ regle: 'nouveauxVoisins', paires: enIds(revues) });
}
if (contraintes.nouvelleTable && revenus.length > 0) {
regles.push({ regle: 'nouvelleTable', personnes: revenus.map((p) => ids[p]) });
}
let nombre = 0;
for (const regle of regles) nombre += (regle.paires ?? regle.personnes).length;
return { table: idsTables[t], nombre, regles };
}),
);
}

View file

@ -0,0 +1,227 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves lourdes des conflits par table (§ 7.5, § 13.2) : les violations
// par table, sommées sur un plan entier, rendent les chiffres de la mesure.
// Σ des paires séparer = collisionsCumulees ; Σ des personnes nouvelle table
// = totalRetoursChoisis ; paires distinctes nouveaux voisins =
// rencontresRepetees.choisies. D'abord les propositions d'une génération sur
// la grande démonstration, puis cent plans tirés sur de petites
// configurations, chacun de sa propre graine.
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 { conflitsParTable } from './conflits.js';
import { LIBRE, indexerPlan, normaliser, planDepuisIndex } from './configuration.js';
import { mesurer } from './indicateurs.js';
import { rechercher } from './recherche.js';
import { verifierInvariants } from './verification.js';
// Une génération courte : la qualité des propositions n'importe pas ici, et
// des propositions qui portent encore des conflits éprouvent mieux l'accord.
const GENERATION = Object.freeze({ graine: 7, arret: 20_000, nombre: 2 });
// Les cent plans tirés : la graine du plan i est GRAINE + i.
const PLANS = 100;
const GRAINE = 2_718;
const PLANS_ACCEPTES_MIN = 90;
// Planchers des comptes de la boucle, posés au-dessous de ce que donnent les
// graines écrites ci-dessus : 100 plans acceptés ; 69 dont les trois totaux
// sont non nuls ; 32 où une paire réunie librement se retrouve à une table où
// les deux sont réservés ; 41 où une personne assise librement à une table y
// revient réservée.
const TOUS_NON_NULS_MIN = 55;
const PAIRE_PUIS_RESERVEE_MIN = 25;
const PERSONNE_PUIS_RESERVEE_MIN = 32;
const LIBELLES = Object.freeze(['Grisaille', 'Club des Merles', 'Ondée']);
// Les trois sommes d'un plan, lues dans conflitsParTable : paires séparer,
// personnes nouvelle table, et paires distinctes nouveaux voisins, clé
// « a,b » en identifiants.
function sommer(conflits) {
let separer = 0;
let nouvelleTable = 0;
const revues = new Set();
for (const tables of conflits) {
for (const { regles } of tables) {
for (const { regle, paires, personnes } of regles) {
if (regle === 'separerAppartenances') separer += paires.length;
else if (regle === 'nouvelleTable') nouvelleTable += personnes.length;
else for (const [a, b] of paires) revues.add(`${a},${b}`);
}
}
}
return { separer, nouvelleTable, nouveauxVoisins: revues.size };
}
// Les écarts entre les sommes par table et la mesure du même plan.
function ecarts(nom, instance, tableDe) {
const sommes = sommer(conflitsParTable(instance, tableDe));
const mesures = mesurer(instance, tableDe);
const attendu = {
separer: mesures.collisionsCumulees,
nouvelleTable: mesures.totalRetoursChoisis,
nouveauxVoisins: mesures.rencontresRepetees.choisies,
};
const fautes = [];
for (const cle of ['separer', 'nouvelleTable', 'nouveauxVoisins']) {
if (sommes[cle] !== attendu[cle]) {
fautes.push(`${nom} : ${cle} ${sommes[cle]} par table, ${attendu[cle]} mesurés`);
}
}
return { fautes, sommes };
}
// Une petite configuration tirée de rng : de 2 à 6 tables de 2 à 5 sièges,
// de 1 à 5 tours, de la moitié des sièges à tous occupés ; une appartenance
// parmi trois, ou aucune, pour chacun. Un participant sur cinq est ancré, un
// sur trois réservé à une partie des tours quand R ≥ 2, chacun à une table
// où il reste un siège à chacun de ses tours : normaliser l'accepte toujours.
// Séparer, nouveaux voisins et nouvelle table sont actifs.
function tirerConfiguration(rng) {
const T = 2 + rng.borne(5);
const capacites = Array.from({ length: T }, () => 2 + rng.borne(4));
const R = 1 + rng.borne(5);
const S = capacites.reduce((somme, c) => somme + c, 0);
const N = S - rng.borne(Math.floor(S / 2) + 1);
const fixees = new Int32Array(T * R);
const siegeLibre = (t, r) => fixees[t * R + r] < capacites[t];
const participants = [];
const reservations = [];
for (let id = 1; id <= N; id += 1) {
const g = rng.borne(LIBELLES.length + 1);
participants.push({ id, nom: `P${id}`, appartenance: g < LIBELLES.length ? LIBELLES[g] : null });
const role = rng.borne(15);
if (role < 3) {
const t = rng.borne(T);
if (Array.from({ length: R }, (_, r) => r).every((r) => siegeLibre(t, r))) {
for (let r = 0; r < R; r += 1) fixees[t * R + r] += 1;
reservations.push({ participant: id, table: t + 1, portee: 'tous' });
}
} else if (role < 8 && R >= 2) {
for (let r = 0; r < R; r += 1) {
if (rng.borne(2) === 0) continue;
const t = rng.borne(T);
if (!siegeLibre(t, r)) continue;
fixees[t * R + r] += 1;
reservations.push({ participant: id, table: t + 1, portee: 'tour', tour: r + 1 });
}
}
}
return {
participants,
tables: capacites.map((capacite, t) => ({ id: t + 1, numero: t + 1, capacite })),
tours: R,
reservations,
contraintes: {
separerAppartenances: true,
nouveauxVoisins: true,
nouvelleTable: true,
varierAppartenances: rng.borne(2) === 0,
},
};
}
// Un plan qui assied chacun à chaque tour : les réservés à leur table, puis
// chaque autre, par index croissants, à une table tirée parmi celles qui lui
// laissent un siège.
function tirerPlan(rng, instance) {
const { N, T, R, capacite, fixe } = instance;
const tableDe = new Int32Array(N * R);
for (let r = 0; r < R; r += 1) {
const occupation = new Int32Array(T);
for (let p = 0; p < N; p += 1) {
const t = fixe[p * R + r];
if (t === LIBRE) continue;
tableDe[p * R + r] = t;
occupation[t] += 1;
}
for (let p = 0; p < N; p += 1) {
if (fixe[p * R + r] !== LIBRE) continue;
const ouvertes = [];
for (let t = 0; t < T; t += 1) if (occupation[t] < capacite[t]) ouvertes.push(t);
const t = ouvertes[rng.borne(ouvertes.length)];
tableDe[p * R + r] = t;
occupation[t] += 1;
}
}
return tableDe;
}
// Vrai quand une paire réunie à une table où les deux ne sont pas réservés
// se retrouve, à un tour suivant, à une table où les deux le sont.
function pairePuisReservee({ N, R, fixe }, tableDe) {
for (let a = 0; a < N; a += 1) {
for (let b = a + 1; b < N; b += 1) {
let libre = false;
for (let r = 0; r < R; r += 1) {
const t = tableDe[a * R + r];
if (t !== tableDe[b * R + r]) continue;
const reservee = fixe[a * R + r] === t && fixe[b * R + r] === t;
if (reservee && libre) return true;
if (!reservee) libre = true;
}
}
}
return false;
}
// Vrai quand une personne assise sans réservation à une table y revient, à
// un tour suivant, réservée.
function personnePuisReservee({ N, T, R, fixe }, tableDe) {
for (let p = 0; p < N; p += 1) {
const libreA = new Uint8Array(T);
for (let r = 0; r < R; r += 1) {
const t = tableDe[p * R + r];
if (fixe[p * R + r] !== t) libreA[t] = 1;
else if (libreA[t] === 1) return true;
}
}
return false;
}
describe('conflits par table : leurs sommes rendent la mesure (§ 7.5, § 13.2)', () => {
test('grande démonstration, deux propositions de 20 000 mouvements', () => {
const configuration = CATALOGUE.find(({ cle }) => cle === 'grande').construire();
const instance = normaliser(configuration);
const propositions = rechercher(configuration, GENERATION);
assert.equal(propositions.length, GENERATION.nombre);
const fautes = [];
for (const { id, plan } of propositions) {
assert.deepEqual(verifierInvariants(instance, plan), [], `proposition ${id}`);
fautes.push(...ecarts(`proposition ${id}`, instance, indexerPlan(instance, plan)).fautes);
}
assert.deepEqual(fautes, []);
});
test('cent plans tirés sur de petites configurations, ancrés et partiellement fixés', () => {
const fautes = [];
const comptes = { acceptes: 0, tousNonNuls: 0, pairePuisReservee: 0, personnePuisReservee: 0 };
for (let i = 1; i <= PLANS; i += 1) {
const rng = creerPcg32(GRAINE + i, FLUX.DEMO);
const instance = normaliser(tirerConfiguration(rng));
const tableDe = tirerPlan(rng, instance);
const violations = verifierInvariants(instance, planDepuisIndex(instance, tableDe));
if (violations.length > 0) {
fautes.push(`graine ${GRAINE + i} : plan refusé, ${JSON.stringify(violations)}`);
continue;
}
comptes.acceptes += 1;
const { fautes: fautesPlan, sommes } = ecarts(`graine ${GRAINE + i}`, instance, tableDe);
fautes.push(...fautesPlan);
if (sommes.separer > 0 && sommes.nouvelleTable > 0 && sommes.nouveauxVoisins > 0) {
comptes.tousNonNuls += 1;
}
if (pairePuisReservee(instance, tableDe)) comptes.pairePuisReservee += 1;
if (personnePuisReservee(instance, tableDe)) comptes.personnePuisReservee += 1;
}
assert.deepEqual(fautes, []);
assert.ok(comptes.acceptes >= PLANS_ACCEPTES_MIN, JSON.stringify(comptes));
assert.ok(comptes.tousNonNuls >= TOUS_NON_NULS_MIN, JSON.stringify(comptes));
assert.ok(comptes.pairePuisReservee >= PAIRE_PUIS_RESERVEE_MIN, JSON.stringify(comptes));
assert.ok(comptes.personnePuisReservee >= PERSONNE_PUIS_RESERVEE_MIN, JSON.stringify(comptes));
});
});

297
src/moteur/conflits.test.js Normal file
View file

@ -0,0 +1,297 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des conflits par table (§ 7.5, § 5.4). Chaque plan s'écrit par
// identifiants, une liste par table, et passe par normaliser et indexerPlan
// comme un plan enregistré ; chaque valeur attendue se compte à la main dans
// le commentaire qui la précède. Les mesures citées viennent de mesurer :
// conflitsParTable et elle lisent le même parcours, et leurs chiffres
// s'accordent par construction.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js';
import { conflitsParTable } from './conflits.js';
import { indexerPlan, normaliser } from './configuration.js';
import { mesurer } from './indicateurs.js';
const TOUTES = Object.freeze({
separerAppartenances: true,
nouveauxVoisins: true,
nouvelleTable: true,
varierAppartenances: true,
});
const MERLES = 'Club des Merles';
const tous = (participant, table) => ({ participant, table, portee: 'tous' });
const auTour = (participant, table, tour) => ({ participant, table, portee: 'tour', tour });
// Une table sans violation.
const sansConflit = (table) => ({ table, nombre: 0, regles: [] });
/**
* Instance et plan indexé d'un plan écrit par identifiants. ids[i] porte
* appartenance[i] (par défaut, l'identifiant i + 1) ; idsTables[i] est
* l'identifiant de la table d'index i (par défaut i + 1), de la capacité
* donnée ; tours[r][i] liste les participants assis à la table d'index i au
* tour r + 1.
*/
function preparer({
appartenance,
ids = appartenance.map((_, i) => i + 1),
capacite,
idsTables,
tours,
reservations = [],
contraintes = TOUTES,
}) {
const participants = appartenance.map((libelle, i) => ({
id: ids[i],
nom: `P${ids[i]}`,
appartenance: libelle,
}));
const tables = (idsTables ?? tours[0].map((_, i) => i + 1)).map((id, i) => ({
id,
numero: i + 1,
capacite,
}));
const instance = normaliser({
participants,
tables,
tours: tours.length,
reservations,
contraintes: { ...contraintes },
});
const plan = { tables: tables.map(({ id }) => id), tours, reserves: tours.map(() => []) };
return { instance, tableDe: indexerPlan(instance, plan) };
}
function conflits(description) {
const { instance, tableDe } = preparer(description);
return conflitsParTable(instance, tableDe);
}
function mesures(description) {
const { instance, tableDe } = preparer(description);
return mesurer(instance, tableDe);
}
// Six personnes, 1 et 2 du Club des Merles, trois tables de 2, deux tours.
const SIX = [MERLES, MERLES, null, null, null, null];
const TOUR_1 = [[3, 4], [1, 2], [5, 6]];
describe('conflitsParTable', () => {
test('séparer : la table 2 réunit 1 et 2 au tour 1, une violation ; aucune autre table, à aucun tour', () => {
// Au tour 2, (2, 6), (3, 5) et (1, 4) se rencontrent pour la première
// fois, et chacun change de table.
const description = { appartenance: SIX, capacite: 2, tours: [TOUR_1, [[2, 6], [3, 5], [1, 4]]] };
assert.deepEqual(conflits(description), [
[
sansConflit(1),
{ table: 2, nombre: 1, regles: [{ regle: 'separerAppartenances', paires: [[1, 2]] }] },
sansConflit(3),
],
[sansConflit(1), sansConflit(2), sansConflit(3)],
]);
const sansSeparer = conflits({
...description,
contraintes: { ...TOUTES, separerAppartenances: false },
});
assert.deepEqual(sansSeparer, [
[sansConflit(1), sansConflit(2), sansConflit(3)],
[sansConflit(1), sansConflit(2), sansConflit(3)],
]);
});
test('nouvelle table : 3 revient à la table 1, 2 à la table 2 ; une visite réservée deux fois ne se signale pas', () => {
// Tour 2 : [3, 6], [2, 5], [1, 4]. 3 était à la table 1 au tour 1, 2 à
// la table 2 ; 6, 5, 1 et 4 découvrent la leur.
const description = { appartenance: SIX, capacite: 2, tours: [TOUR_1, [[3, 6], [2, 5], [1, 4]]] };
const auTour2 = (resultat) => resultat[1];
assert.deepEqual(auTour2(conflits(description)), [
{ table: 1, nombre: 1, regles: [{ regle: 'nouvelleTable', personnes: [3] }] },
{ table: 2, nombre: 1, regles: [{ regle: 'nouvelleTable', personnes: [2] }] },
sansConflit(3),
]);
// 3 ancré à la table 1 : réservé au tour 2 et à sa visite du tour 1, son
// retour est imposé.
const ancre = { ...description, reservations: [tous(3, 1)] };
assert.deepEqual(auTour2(conflits(ancre)), [
sansConflit(1),
{ table: 2, nombre: 1, regles: [{ regle: 'nouvelleTable', personnes: [2] }] },
sansConflit(3),
]);
assert.deepEqual(mesures(ancre).retoursImposes, [0, 0, 1, 0, 0, 0]);
// 3 réservé à la table 1 au seul tour 2 : sa visite du tour 1 était
// libre, le retour est choisi, et la mesure le compte choisi.
const tour2 = { ...description, reservations: [auTour(3, 1, 2)] };
assert.deepEqual(auTour2(conflits(tour2))[0], {
table: 1,
nombre: 1,
regles: [{ regle: 'nouvelleTable', personnes: [3] }],
});
assert.deepEqual(mesures(tour2).retoursChoisis, [0, 1, 1, 0, 0, 0]);
assert.deepEqual(mesures(tour2).retoursImposes, [0, 0, 0, 0, 0, 0]);
});
test('nouveaux voisins : une paire réunie librement puis réservée ensemble reste signalée ; réservée aux deux rencontres, elle ne l’est plus', () => {
// Quatre personnes sans appartenance, deux tables de 2. Tour 1 : [1, 2],
// [3, 4] ; tour 2 : [3, 4], [1, 2]. Personne ne revient à sa table.
const description = {
appartenance: [null, null, null, null],
capacite: 2,
tours: [
[[1, 2], [3, 4]],
[[3, 4], [1, 2]],
],
reservations: [auTour(3, 1, 2), auTour(4, 1, 2)],
};
assert.deepEqual(conflits(description)[1], [
{ table: 1, nombre: 1, regles: [{ regle: 'nouveauxVoisins', paires: [[3, 4]] }] },
{ table: 2, nombre: 1, regles: [{ regle: 'nouveauxVoisins', paires: [[1, 2]] }] },
]);
assert.deepEqual(mesures(description).rencontresRepetees, { choisies: 2, imposees: 0 });
// 3 et 4 réservés aussi à la table 2 au tour 1 : chacune de leurs
// rencontres est réservée pour les deux.
const imposee = {
...description,
reservations: [...description.reservations, auTour(3, 2, 1), auTour(4, 2, 1)],
};
assert.deepEqual(conflits(imposee)[1], [
sansConflit(1),
{ table: 2, nombre: 1, regles: [{ regle: 'nouveauxVoisins', paires: [[1, 2]] }] },
]);
assert.deepEqual(mesures(imposee).rencontresRepetees, { choisies: 1, imposees: 1 });
});
test('les trois règles à une même table, dans leur ordre, comptées ensemble, en identifiants', () => {
// Participants 5, 9, 14 et 20 ; 5 et 9 de Grisaille. Tables d'identifiants
// 40 puis 10, dans cet ordre de configuration. Les deux mêmes paires aux
// deux tours : au tour 2, la table 40 porte (5, 9) collègues et revus, 5
// et 9 revenus, quatre violations ; la table 10, (14, 20) revus, 14 et
// 20 revenus, trois.
const description = {
appartenance: ['Grisaille', 'Grisaille', null, null],
ids: [5, 9, 14, 20],
idsTables: [40, 10],
capacite: 2,
tours: [
[[5, 9], [14, 20]],
[[5, 9], [14, 20]],
],
};
assert.deepEqual(conflits(description), [
[
{ table: 40, nombre: 1, regles: [{ regle: 'separerAppartenances', paires: [[5, 9]] }] },
sansConflit(10),
],
[
{
table: 40,
nombre: 4,
regles: [
{ regle: 'separerAppartenances', paires: [[5, 9]] },
{ regle: 'nouveauxVoisins', paires: [[5, 9]] },
{ regle: 'nouvelleTable', personnes: [5, 9] },
],
},
{
table: 10,
nombre: 3,
regles: [
{ regle: 'nouveauxVoisins', paires: [[14, 20]] },
{ regle: 'nouvelleTable', personnes: [14, 20] },
],
},
],
]);
// Une règle inactive est omise, les autres gardent leur ordre.
const sansVoisins = conflits({ ...description, contraintes: { ...TOUTES, nouveauxVoisins: false } });
assert.deepEqual(sansVoisins[1][0], {
table: 40,
nombre: 3,
regles: [
{ regle: 'separerAppartenances', paires: [[5, 9]] },
{ regle: 'nouvelleTable', personnes: [5, 9] },
],
});
const sansTable = conflits({ ...description, contraintes: { ...TOUTES, nouvelleTable: false } });
assert.deepEqual(sansTable[1][1], {
table: 10,
nombre: 1,
regles: [{ regle: 'nouveauxVoisins', paires: [[14, 20]] }],
});
});
test('une table de plusieurs collègues : chaque paire, identifiants croissants, paires dans l’ordre lexicographique', () => {
// 2, 4, 6 et 8 du Club des Merles, assis tous les quatre à une table de 5
// avec 3, donné avant eux dans la liste : C(4, 2) = 6 paires.
const resultat = conflits({
appartenance: [null, MERLES, null, MERLES, MERLES, MERLES],
ids: [1, 2, 3, 4, 6, 8],
capacite: 5,
tours: [[[8, 6, 3, 4, 2], [1]]],
});
assert.deepEqual(resultat[0][0], {
table: 1,
nombre: 6,
regles: [
{
regle: 'separerAppartenances',
paires: [[2, 4], [2, 6], [2, 8], [4, 6], [4, 8], [6, 8]],
},
],
});
});
test('une seule table, un seul tour : aucune violation, des répétitions à zéro, sans lever (§ 15.4)', () => {
// Trois personnes, 3 seule du Club des Merles. 1 et 2 rencontrent un
// affilié d'une appartenance : A = 1, d = 1 ; 3 n'en rencontre aucun :
// A = 0, et il sort de l'effectif de la diversité.
const description = {
appartenance: [null, null, MERLES],
capacite: 3,
tours: [[[1, 2, 3]]],
};
assert.deepEqual(conflits(description), [[sansConflit(1)]]);
const { rencontresRepetees, aggAppartenancesVues, aggDiversite } = mesures(description);
assert.deepEqual(rencontresRepetees, { choisies: 0, imposees: 0 });
assert.deepEqual(aggAppartenancesVues.tous, { min: 0, moyenne: 2 / 3, effectif: 3 });
assert.deepEqual(aggDiversite.tous, { min: 1, moyenne: 1, effectif: 2 });
});
test('aucun présent : une table, deux tours, chacun à zéro, sans lever', () => {
const instance = normaliser({
participants: [{ id: 1, nom: 'P1', appartenance: MERLES, exclu: true }],
tables: [{ id: 1, numero: 1, capacite: 2 }],
tours: 2,
reservations: [],
contraintes: { ...TOUTES },
});
assert.deepEqual(conflitsParTable(instance, new Int32Array(0)), [[sansConflit(1)], [sansConflit(1)]]);
});
test('plan parfait de la petite démonstration : aucune violation, à chaque table de chaque tour', () => {
const configuration = CATALOGUE.find(({ cle }) => cle === 'petite').construire();
const instance = normaliser({ ...configuration, contraintes: { ...TOUTES } });
const resultat = conflitsParTable(instance, indexerPlan(instance, PLAN_PARFAIT_PETITE));
const cases = resultat.flat();
assert.equal(cases.length, 16);
for (const [k, { nombre, regles }] of cases.entries()) {
assert.equal(nombre, 0, `case ${k}`);
assert.deepEqual(regles, [], `case ${k}`);
}
});
test("lève ce que lève exigerPlanIndexe, et ne modifie ni l'instance ni le plan", () => {
const { instance, tableDe } = preparer({
appartenance: SIX,
capacite: 2,
tours: [TOUR_1, [[3, 6], [2, 5], [1, 4]]],
});
assert.throws(() => conflitsParTable(instance, tableDe.subarray(1)), RangeError);
const avant = { tableDe: tableDe.slice(), fixe: instance.fixe.slice(), groupe: instance.groupe.slice() };
conflitsParTable(instance, tableDe);
assert.deepEqual({ tableDe, fixe: instance.fixe, groupe: instance.groupe }, avant);
});
});

View file

@ -14,6 +14,11 @@
// configuration.js, plafond a priori de plafond.js, retours imposés de la // 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 // mesure d'indicateurs.js. Le diagnostic ne modifie pas la configuration et
// lève ce que normaliser lève. // lève ce que normaliser lève.
//
// Un remède se chiffre en sollicitant la même arithmétique sur une
// configuration modifiée — plus de tables, moins de tours —, jamais par une
// forme close de plus (§ 13.2) : la sonde passe par normaliser, puis par la
// fonction qui chiffre la quantité sur la configuration elle-même.
/** /**
* @typedef {Object} PlancherCollisions * @typedef {Object} PlancherCollisions
@ -23,21 +28,39 @@
* @property {number} plancherPairesDistinctes * @property {number} plancherPairesDistinctes
* @property {number|null} plancherExcedent null quand il n'est pas * @property {number|null} plancherExcedent null quand il n'est pas
* strictement positif * strictement positif
* @property {number|null} tablesManquantes plus petit nombre de tables, de
* la plus grande capacité présente, dont l'ajout ramène ce plancher
* à 0 ; null quand l'effectif du groupe n'y suffit pas : les
* réservations assoient alors deux membres à une même table d'un tour
* *
* @typedef {Object} Diagnostic * @typedef {Object} Diagnostic
* @property {number} placesManquantes max(0, N − Σ c_t), par tour * @property {number} placesManquantes max(0, N − Σ c_t), par tour
* @property {{min: number|null, max: number|null, parPersonne: number[]}} plafondAPriori * @property {{min: number|null, max: number|null, parPersonne: number[],
* repartition: Array<{valeur: number, ancres: number, mobiles: number}>}} plafondAPriori
* parPersonne dans l'ordre de instance.ids ; min et max null pour * parPersonne dans l'ordre de instance.ids ; min et max null pour
* une population vide * une population vide ; repartition, une ligne par valeur
* présente, valeurs croissantes, les partiellement fixés parmi les
* mobiles
* @property {PlancherCollisions[]} collisions groupes dont le plancher de * @property {PlancherCollisions[]} collisions groupes dont le plancher de
* collisions cumulées est > 0, dans l'ordre de instance.groupes * collisions cumulées est > 0, dans l'ordre de instance.groupes
* @property {number|null} redondanceMinimale null sans affilié * @property {number|null} redondanceMinimale null sans affilié
* @property {{toursSansRedondance: number|null}} remedes
* toursSansRedondance : plus grand R′ < R où redondanceMinimale vaut
* 0 ; null quand elle vaut déjà 0 ou null, ou qu'aucun R′ ne l'annule
* @property {boolean} animateursMemeAppartenance * @property {boolean} animateursMemeAppartenance
* @property {number} redondanceImposeeParAnimateurs min(R, T) − 1 ou 0 * @property {number} redondanceImposeeParAnimateurs min(R, T) − 1 ou 0
* @property {Array<{table: number, ancres: number, capacite: number, gelee: boolean}>} ancrage * @property {boolean} uneSeuleAppartenance séparation active, une seule
* appartenance, portée par chaque présent (§ 15.4)
* @property {boolean} collisionPartout tout plan qui assied chacun
* porte une collision à chaque table de chaque tour (§ 7.5)
* @property {Array<{table: number, ancres: number, capacite: number, gelee: boolean,
* moitie: boolean}>} ancrage
* chaque table, par identifiant, dans l'ordre de la configuration ; * chaque table, par identifiant, dans l'ordre de la configuration ;
* gelee quand ses ancrés occupent tous ses sièges * gelee quand ses ancrés occupent tous ses sièges ; moitie quand ils
* en occupent la moitié au moins, sans la geler
* @property {number} retoursImposes * @property {number} retoursImposes
* @property {number} ancragesImposantRetours personnes dont les réservations
* imposent un retour
* @property {{plancher: number, mobilesAuMoins: number}|null} ecartItineraire * @property {{plancher: number, mobilesAuMoins: number}|null} ecartItineraire
*/ */
import { import {
@ -154,14 +177,110 @@ function animateursMemeAppartenance({ N, statut, groupe }) {
// Ancrés et capacité de chaque table, dans l'ordre de l'instance, qui est // 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 // celui de la configuration ; gelee quand les ancrés occupent tous les sièges
// (§ 5.9). // (§ 5.9) ; moitie quand ils en occupent la moitié au moins, 2 × a_t ≥ c_t,
// sur une table qui tourne encore.
function ancrage({ T, idsTables, capacite, ancresParTable }) { function ancrage({ T, idsTables, capacite, ancresParTable }) {
return Array.from({ length: T }, (_, t) => ({ return Array.from({ length: T }, (_, t) => {
table: idsTables[t], const gelee = ancresParTable[t] === capacite[t];
ancres: ancresParTable[t], return {
capacite: capacite[t], table: idsTables[t],
gelee: ancresParTable[t] === capacite[t], ancres: ancresParTable[t],
})); capacite: capacite[t],
gelee,
moitie: !gelee && 2 * ancresParTable[t] >= capacite[t],
};
});
}
// Plafonds a priori regroupés par valeur, valeurs croissantes : à chacune,
// les ancrés et les mobiles, partiellement fixés compris (§ 5.4), qui la
// portent. Les personnes se parcourent par plafond, puis par index.
function repartition(aPriori, statut) {
const ordre = Array.from({ length: aPriori.length }, (_, p) => p);
ordre.sort((p, q) => aPriori[p] - aPriori[q] || p - q);
const lignes = [];
for (const p of ordre) {
let ligne = lignes[lignes.length - 1];
if (ligne === undefined || ligne.valeur !== aPriori[p]) {
ligne = { valeur: aPriori[p], ancres: 0, mobiles: 0 };
lignes.push(ligne);
}
if (statut[p] === STATUT.ANCRE) ligne.ancres += 1;
else ligne.mobiles += 1;
}
return lignes;
}
// Chaque plancher de collisions, complété de tablesManquantes. La sonde à m
// tables ajoute m tables de la plus grande capacité présente, d'identifiants
// au-delà du plus grand, et rechiffre les planchers par planchersCollisions :
// le premier m où le groupe n'y figure plus est son remède. Un groupe de s
// membres se sépare dès que s tables de plus s'offrent à ses membres libres,
// sauf quand des réservations en assoient deux à une même table d'un tour :
// au-delà de s, il garde null. Les capacités, relâchées dans le plancher, ne
// changent pas le compte ; elles disent quelles tables ajouter. Un plancher
// suppose une table au moins.
function avecTablesManquantes(configuration, instance, planchers) {
const lignes = planchers.map((plancher) => ({ ...plancher, tablesManquantes: null }));
if (lignes.length === 0) return lignes;
const enAttente = (m) => lignes.some((l) => l.tablesManquantes === null && m <= l.effectif);
const capacite = Math.max(...instance.capacite);
const premierId = Math.max(...instance.idsTables) + 1;
const ajoutees = [];
for (let m = 1; enAttente(m); m += 1) {
ajoutees.push({ id: premierId + m - 1, numero: configuration.tables.length + m, capacite });
const sonde = normaliser({ ...configuration, tables: [...configuration.tables, ...ajoutees] });
const restants = new Set(planchersCollisions(sonde).map(({ groupe }) => groupe));
for (const ligne of lignes) {
if (ligne.tablesManquantes === null && m <= ligne.effectif && !restants.has(ligne.groupe)) {
ligne.tablesManquantes = m;
}
}
}
return lignes;
}
// Plus grand R′ < R où la redondance minimale s'annule, null quand elle vaut
// déjà 0 ou null, ou qu'aucun R′ ne l'annule. La sonde à R′ tours garde les
// réservations de portée « tous », qui couvrent alors ses R′ tours, et retire
// celles d'un tour au-delà de R′, exclus compris : normaliser les refuserait
// (RESERVATION_TOUR). Les R′ se sondent du plus grand au plus petit.
function toursSansRedondance(configuration, R, minimale) {
if (minimale === null || minimale === 0) return null;
for (let tours = R - 1; tours >= 1; tours -= 1) {
const reservations = configuration.reservations.filter(
({ portee, tour }) => portee !== 'tour' || tour <= tours,
);
const sonde = normaliser({ ...configuration, tours, reservations });
if (redondanceMinimale(sonde, plafondsAPriori(sonde)) === 0) return tours;
}
return null;
}
// Vrai quand la séparation est active et que les présents portent tous une
// même appartenance, la seule (§ 15.4) ; faux dès qu'un présent est sans
// appartenance : la séparation y garde un sens.
function uneSeuleAppartenance({ groupe, groupes, contraintes }) {
return contraintes.separerAppartenances && groupes.length === 1 && !groupe.includes(SANS_GROUPE);
}
// Vrai quand tout plan qui assied chacun porte une collision à chaque table
// de chaque tour (§ 7.5). Les autres tables tiennent au plus Σ c − c_t
// personnes : la table t en reçoit au moins N − (Σ c − c_t). Sans collision,
// elle n'assied qu'un membre de chacune des G appartenances et les U présents
// sans appartenance : au-delà de G + U, la collision est forcée. Faux quand
// la séparation est inactive, qu'une place manque — aucun plan n'assied
// chacun —, ou sans table : rien ne le prouve.
function collisionPartout(instance, placesManquantes) {
const { N, T, capacite, groupe, groupes, contraintes } = instance;
if (!contraintes.separerAppartenances || placesManquantes > 0 || T === 0) return false;
let sansAppartenance = 0;
for (let p = 0; p < N; p += 1) if (groupe[p] === SANS_GROUPE) sansAppartenance += 1;
const places = nombrePlaces(instance);
for (let t = 0; t < T; t += 1) {
if (N - (places - capacite[t]) <= groupes.length + sansAppartenance) return false;
}
return true;
} }
// Plafond du meilleur itinéraire d'un mobile p qui passe par la table t : le // Plafond du meilleur itinéraire d'un mobile p qui passe par la table t : le
@ -249,19 +368,23 @@ function etendue(valeurs) {
* *
* - placesManquantes : nombrePlacesManquantes ; la recherche refuse une * - placesManquantes : nombrePlacesManquantes ; la recherche refuse une
* configuration où elles sont positives, le diagnostic les chiffre (§ 5.9). * configuration où elles sont positives, le diagnostic les chiffre (§ 5.9).
* - plafondAPriori : plafondsAPriori, et son étendue. * - plafondAPriori : plafondsAPriori, son étendue et sa répartition par
* valeur.
* - collisions : planchers de collisions par groupe, posés sur les tours * - collisions : planchers de collisions par groupe, posés sur les tours
* fixés, capacités relâchées. * fixés, capacités relâchées ; chacun avec les tables qui l'annulent.
* - redondanceMinimale : max_p max(0, min(a priori(p), affiliés autres que * - redondanceMinimale : max_p max(0, min(a priori(p), affiliés autres que
* p) − G). * p) − G) ; remedes.toursSansRedondance, le nombre de tours qui l'annule.
* - animateursMemeAppartenance, et redondanceImposeeParAnimateurs : la forme * - animateursMemeAppartenance, et redondanceImposeeParAnimateurs : la forme
* close min(R, T) − 1 du § 5.6, qui compte un animateur à chaque table * 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. * visitée ; 0 quand les animateurs ne partagent pas une appartenance.
* - ancrage : ancrés, capacité et gel de chaque table. * - uneSeuleAppartenance et collisionPartout : une séparation vide de sens,
* une collision forcée à chaque table.
* - ancrage : ancrés, capacité, gel et demi-ancrage de chaque table.
* - retoursImposes : la rangée de instance.fixe se lit comme le plan qui * - 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 * 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 * 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). * que mesure tout plan qui honore les réservations (§ 5.4) ;
* ancragesImposantRetours, les personnes qui en portent un au moins.
* - ecartItineraire : plancher de l'écart d'itinéraire maximal, ou null. * - ecartItineraire : plancher de l'écart d'itinéraire maximal, ou null.
* *
* @param {import('./types.js').Configuration} configuration * @param {import('./types.js').Configuration} configuration
@ -271,15 +394,26 @@ export function diagnostiquer(configuration) {
const instance = normaliser(configuration); const instance = normaliser(configuration);
const aPriori = plafondsAPriori(instance); const aPriori = plafondsAPriori(instance);
const memeAppartenance = animateursMemeAppartenance(instance); const memeAppartenance = animateursMemeAppartenance(instance);
const placesManquantes = nombrePlacesManquantes(instance);
const minimale = redondanceMinimale(instance, aPriori);
const imposes = mesurer(instance, instance.fixe);
return { return {
placesManquantes: nombrePlacesManquantes(instance), placesManquantes,
plafondAPriori: { ...etendue(aPriori), parPersonne: aPriori }, plafondAPriori: {
collisions: planchersCollisions(instance), ...etendue(aPriori),
redondanceMinimale: redondanceMinimale(instance, aPriori), parPersonne: aPriori,
repartition: repartition(aPriori, instance.statut),
},
collisions: avecTablesManquantes(configuration, instance, planchersCollisions(instance)),
redondanceMinimale: minimale,
remedes: { toursSansRedondance: toursSansRedondance(configuration, instance.R, minimale) },
animateursMemeAppartenance: memeAppartenance, animateursMemeAppartenance: memeAppartenance,
redondanceImposeeParAnimateurs: memeAppartenance ? Math.min(instance.R, instance.T) - 1 : 0, redondanceImposeeParAnimateurs: memeAppartenance ? Math.min(instance.R, instance.T) - 1 : 0,
uneSeuleAppartenance: uneSeuleAppartenance(instance),
collisionPartout: collisionPartout(instance, placesManquantes),
ancrage: ancrage(instance), ancrage: ancrage(instance),
retoursImposes: mesurer(instance, instance.fixe).totalRetoursImposes, retoursImposes: imposes.totalRetoursImposes,
ancragesImposantRetours: imposes.retoursImposes.filter((retours) => retours > 0).length,
ecartItineraire: ecartItineraire(configuration, instance, aPriori), ecartItineraire: ecartItineraire(configuration, instance, aPriori),
}; };
} }

View file

@ -11,6 +11,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js'; import { describe, test } from '../../test/lanceur.js';
import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js'; import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js';
import { conflitsParTable } from './conflits.js';
import { LIBRE, STATUT, indexerPlan, normaliser } from './configuration.js'; import { LIBRE, STATUT, indexerPlan, normaliser } from './configuration.js';
import { diagnostiquer } from './diagnostic.js'; import { diagnostiquer } from './diagnostic.js';
import { ErreurConfiguration } from './erreurs.js'; import { ErreurConfiguration } from './erreurs.js';
@ -23,15 +24,31 @@ const SANS_CONTRAINTE = {
nouvelleTable: false, nouvelleTable: false,
varierAppartenances: false, varierAppartenances: false,
}; };
const TOUTES = {
separerAppartenances: true,
nouveauxVoisins: true,
nouvelleTable: true,
varierAppartenances: true,
};
const SEPARER = { ...SANS_CONTRAINTE, separerAppartenances: true };
const TOUTES_SAUF_SEPARER = { ...TOUTES, separerAppartenances: false };
/** /**
* Configuration écrite à la main. tables porte une paire [id, capacité] par * Configuration écrite à la main. tables porte une paire [id, capacité] par
* table, dans l'ordre de la configuration, le numéro suivant la position ; * table, dans l'ordre de la configuration, le numéro suivant la position ;
* appartenances[j] est celle du participant d'id j + 1 ; exclus nomme des ids. * appartenances[j] est celle du participant d'id j + 1 ; exclus nomme des ids ;
* contraintes, aucune par défaut.
* Les identifiants de table diffèrent des index et des numéros : un index lu * Les identifiants de table diffèrent des index et des numéros : un index lu
* comme un identifiant désigne une table absente. * comme un identifiant désigne une table absente.
*/ */
function salle({ tables, appartenances, tours, reservations = [], exclus = [] }) { function salle({
tables,
appartenances,
tours,
reservations = [],
exclus = [],
contraintes = SANS_CONTRAINTE,
}) {
return { return {
participants: appartenances.map((appartenance, j) => ({ participants: appartenances.map((appartenance, j) => ({
id: j + 1, id: j + 1,
@ -42,7 +59,7 @@ function salle({ tables, appartenances, tours, reservations = [], exclus = [] })
tables: tables.map(([id, capacite], i) => ({ id, numero: i + 1, capacite })), tables: tables.map(([id, capacite], i) => ({ id, numero: i + 1, capacite })),
tours, tours,
reservations, reservations,
contraintes: SANS_CONTRAINTE, contraintes: { ...contraintes },
}; };
} }
@ -217,8 +234,20 @@ describe('diagnostic : le catalogue (§ 15)', () => {
ancres: 1, ancres: 1,
capacite: t < 29 ? 8 : 7, capacite: t < 29 ? 8 : 7,
gelee: false, gelee: false,
moitie: false,
})), })),
); );
// 24 aux quatre animateurs des tables de 7, 28 à chacun des autres ; les
// 227 mobiles sont tous à 28.
assert.deepEqual(diagnostic.plafondAPriori.repartition, [
{ valeur: 24, ancres: 4, mobiles: 0 },
{ valeur: 28, ancres: 29, mobiles: 227 },
]);
// Chacun des 33 animateurs revient trois fois à sa table.
assert.equal(diagnostic.ancragesImposantRetours, 33);
assert.equal(diagnostic.uneSeuleAppartenance, false);
assert.equal(diagnostic.collisionPartout, false);
assert.deepEqual(diagnostic.remedes, { toursSansRedondance: null });
}); });
// 264 sièges pour 260 membres : la configuration n'est pas tendue. // 264 sièges pour 260 membres : la configuration n'est pas tendue.
@ -250,6 +279,7 @@ describe('diagnostic : le catalogue (§ 15)', () => {
plancherCumulees: 4, plancherCumulees: 4,
plancherPairesDistinctes: 1, plancherPairesDistinctes: 1,
plancherExcedent: null, plancherExcedent: null,
tablesManquantes: 1,
}, },
]); ]);
}); });
@ -403,6 +433,7 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
plancherCumulees: 5, plancherCumulees: 5,
plancherPairesDistinctes: 1, plancherPairesDistinctes: 1,
plancherExcedent: 2, plancherExcedent: 2,
tablesManquantes: 1,
}, },
]); ]);
assert.deepEqual(minimumsParGroupe(configuration).get('X'), { assert.deepEqual(minimumsParGroupe(configuration).get('X'), {
@ -430,7 +461,14 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
tours: 4, tours: 4,
}); });
assert.deepEqual(diagnostiquer(configuration).collisions, [ assert.deepEqual(diagnostiquer(configuration).collisions, [
{ groupe: 'X', effectif: 6, plancherCumulees: 8, plancherPairesDistinctes: 2, plancherExcedent: null }, {
groupe: 'X',
effectif: 6,
plancherCumulees: 8,
plancherPairesDistinctes: 2,
plancherExcedent: null,
tablesManquantes: 2,
},
]); ]);
}); });
@ -441,7 +479,14 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
tours: 2, tours: 2,
}); });
assert.deepEqual(diagnostiquer(configuration).collisions, [ assert.deepEqual(diagnostiquer(configuration).collisions, [
{ groupe: 'X', effectif: 6, plancherCumulees: 12, plancherPairesDistinctes: 6, plancherExcedent: null }, {
groupe: 'X',
effectif: 6,
plancherCumulees: 12,
plancherPairesDistinctes: 6,
plancherExcedent: null,
tablesManquantes: 4,
},
]); ]);
const exacts = minimumsParGroupe(configuration).get('X'); const exacts = minimumsParGroupe(configuration).get('X');
assert.equal(exacts.cumulees, 12); assert.equal(exacts.cumulees, 12);
@ -455,7 +500,14 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
tours: 3, tours: 3,
}); });
assert.deepEqual(diagnostiquer(configuration).collisions, [ assert.deepEqual(diagnostiquer(configuration).collisions, [
{ groupe: 'X', effectif: 5, plancherCumulees: 12, plancherPairesDistinctes: 4, plancherExcedent: 2 }, {
groupe: 'X',
effectif: 5,
plancherCumulees: 12,
plancherPairesDistinctes: 4,
plancherExcedent: 2,
tablesManquantes: 3,
},
]); ]);
assert.deepEqual(minimumsParGroupe(configuration).get('X'), { cumulees: 12, distinctes: 4, excedent: 2 }); assert.deepEqual(minimumsParGroupe(configuration).get('X'), { cumulees: 12, distinctes: 4, excedent: 2 });
}); });
@ -473,6 +525,7 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
plancherCumulees: 4, plancherCumulees: 4,
plancherPairesDistinctes: 1, plancherPairesDistinctes: 1,
plancherExcedent: 1, plancherExcedent: 1,
tablesManquantes: 1,
}, },
]); ]);
assert.deepEqual(minimumsParGroupe(configuration).get('X'), { assert.deepEqual(minimumsParGroupe(configuration).get('X'), {
@ -502,6 +555,7 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
plancherCumulees: 3, plancherCumulees: 3,
plancherPairesDistinctes: 1, plancherPairesDistinctes: 1,
plancherExcedent: null, plancherExcedent: null,
tablesManquantes: null,
}, },
{ {
groupe: 'Y', groupe: 'Y',
@ -509,6 +563,7 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
plancherCumulees: 1, plancherCumulees: 1,
plancherPairesDistinctes: 1, plancherPairesDistinctes: 1,
plancherExcedent: null, plancherExcedent: null,
tablesManquantes: null,
}, },
]); ]);
const minimums = minimumsParGroupe(configuration); const minimums = minimumsParGroupe(configuration);
@ -532,6 +587,7 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
plancherCumulees: 3, plancherCumulees: 3,
plancherPairesDistinctes: 1, plancherPairesDistinctes: 1,
plancherExcedent: 2, plancherExcedent: 2,
tablesManquantes: null,
}, },
]); ]);
}); });
@ -552,6 +608,7 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
plancherCumulees: 2, plancherCumulees: 2,
plancherPairesDistinctes: 1, plancherPairesDistinctes: 1,
plancherExcedent: null, plancherExcedent: null,
tablesManquantes: 1,
}, },
]); ]);
}); });
@ -933,9 +990,9 @@ describe('diagnostic : ancrage et retours imposés (§ 5.4, § 5.6)', () => {
reservations: [ancrer(1, 30), ancrer(2, 30), ancrer(3, 10), ancrer(4, 10)], reservations: [ancrer(1, 30), ancrer(2, 30), ancrer(3, 10), ancrer(4, 10)],
}); });
assert.deepEqual(diagnostiquer(configuration).ancrage, [ assert.deepEqual(diagnostiquer(configuration).ancrage, [
{ table: 30, ancres: 2, capacite: 2, gelee: true }, { table: 30, ancres: 2, capacite: 2, gelee: true, moitie: false },
{ table: 10, ancres: 2, capacite: 3, gelee: false }, { table: 10, ancres: 2, capacite: 3, gelee: false, moitie: true },
{ table: 20, ancres: 0, capacite: 2, gelee: false }, { table: 20, ancres: 0, capacite: 2, gelee: false, moitie: false },
]); ]);
}); });
@ -964,6 +1021,8 @@ describe('diagnostic : ancrage et retours imposés (§ 5.4, § 5.6)', () => {
}); });
const diagnostic = diagnostiquer(configuration); const diagnostic = diagnostiquer(configuration);
assert.equal(diagnostic.retoursImposes, 7); assert.equal(diagnostic.retoursImposes, 7);
// 1, 2 et 5 portent des retours imposés ; 3 n'en porte aucun.
assert.equal(diagnostic.ancragesImposantRetours, 3);
assert.deepEqual( assert.deepEqual(
diagnostic.ancrage.map(({ ancres }) => ancres), diagnostic.ancrage.map(({ ancres }) => ancres),
[2, 0], [2, 0],
@ -998,25 +1057,34 @@ describe('diagnostic : population vide, forme, refus', () => {
test('une population vide rend null, jamais 0, là où rien ne se mesure', () => { test('une population vide rend null, jamais 0, là où rien ne se mesure', () => {
const attendu = { const attendu = {
placesManquantes: 0, placesManquantes: 0,
plafondAPriori: { min: null, max: null, parPersonne: [] }, plafondAPriori: { min: null, max: null, parPersonne: [], repartition: [] },
collisions: [], collisions: [],
redondanceMinimale: null, redondanceMinimale: null,
remedes: { toursSansRedondance: null },
animateursMemeAppartenance: false, animateursMemeAppartenance: false,
redondanceImposeeParAnimateurs: 0, redondanceImposeeParAnimateurs: 0,
ancrage: [{ table: 10, ancres: 0, capacite: 4, gelee: false }], uneSeuleAppartenance: false,
collisionPartout: false,
ancrage: [{ table: 10, ancres: 0, capacite: 4, gelee: false, moitie: false }],
retoursImposes: 0, retoursImposes: 0,
ancragesImposantRetours: 0,
ecartItineraire: null, ecartItineraire: null,
}; };
const vide = salle({ tables: [[10, 4]], appartenances: [], tours: 2 }); // Les mêmes chiffres, séparation active ou non : sans présent, aucune
assert.deepEqual(diagnostiquer(vide), attendu); // appartenance n'est portée par chacun, et aucune table n'est forcée.
const tousExclus = salle({ for (const contraintes of [SANS_CONTRAINTE, TOUTES]) {
tables: [[10, 4]], const vide = salle({ tables: [[10, 4]], appartenances: [], tours: 2, contraintes });
appartenances: ['X', 'X', 'X'], assert.deepEqual(diagnostiquer(vide), attendu);
tours: 2, const tousExclus = salle({
reservations: [ancrer(1, 10), ancrer(2, 10)], tables: [[10, 4]],
exclus: [1, 2, 3], appartenances: ['X', 'X', 'X'],
}); tours: 2,
assert.deepEqual(diagnostiquer(tousExclus), attendu); reservations: [ancrer(1, 10), ancrer(2, 10)],
exclus: [1, 2, 3],
contraintes,
});
assert.deepEqual(diagnostiquer(tousExclus), attendu);
}
}); });
test('le diagnostic ne modifie pas la configuration', () => { test('le diagnostic ne modifie pas la configuration', () => {
@ -1041,3 +1109,310 @@ describe('diagnostic : population vide, forme, refus', () => {
); );
}); });
}); });
describe('diagnostic : répartition du plafond a priori (§ 5.6)', () => {
// 1 et 2, sans appartenance, ancrés à la table 10 de 3 sièges ; quatre
// mobiles de X, deux tours : les ancrés plafonnent à 3, les mobiles à 4
// (voir « un affilié ne se compte pas parmi les affiliés qu'il
// rencontre »).
test('une ligne par valeur, croissante, ancrés et mobiles comptés à part', () => {
const configuration = salle({
tables: [[10, 3], [20, 3]],
appartenances: [null, null, ...fois(4, 'X')],
tours: 2,
reservations: [ancrer(1, 10), ancrer(2, 10)],
});
assert.deepEqual(diagnostiquer(configuration).plafondAPriori.repartition, [
{ valeur: 3, ancres: 2, mobiles: 0 },
{ valeur: 4, ancres: 0, mobiles: 4 },
]);
});
// Un partiellement fixé compte parmi les mobiles (§ 5.4) : 1, réservé à la
// table 10 au seul tour 1, plafonne comme les autres, la table 10 restant
// libre à l'autre tour. Six personnes sur deux tables de 3, deux tours :
// chacun rencontre 2 voisins par tour, 4 en tout.
test('un partiellement fixé se compte parmi les mobiles', () => {
const configuration = salle({
tables: [[10, 3], [20, 3]],
appartenances: fois(6, null),
tours: 2,
reservations: [fixer(1, 10, 1)],
});
assert.deepEqual(diagnostiquer(configuration).plafondAPriori.repartition, [
{ valeur: 4, ancres: 0, mobiles: 6 },
]);
});
});
describe('diagnostic : tables manquantes (§ 5.6, § 15.3)', () => {
// Le remède compte des tables de la plus grande capacité présente ; le
// plancher, capacités relâchées, n'en dépend pas. Tables de 2 et de 6 :
// quatre membres de X pour deux tables, deux de plus les séparent.
test('des tables de capacités inégales : le compte ne lit que le plancher', () => {
const configuration = salle({
tables: [[10, 2], [20, 6]],
appartenances: [...fois(4, 'X'), ...fois(4, null)],
tours: 2,
});
assert.deepEqual(
diagnostiquer(configuration).collisions.map(({ tablesManquantes }) => tablesManquantes),
[2],
);
});
// Deux groupes : Y, réservé ensemble à la table 10 au tour 2, n'a pas de
// remède ; X, cinq membres libres sur trois tables, en a un, 2 tables.
test('un groupe sans remède n’empêche pas le remède d’un autre', () => {
const configuration = salle({
tables: [[10, 4], [20, 4], [30, 4]],
appartenances: [...fois(5, 'X'), 'Y', 'Y', null],
tours: 2,
reservations: [fixer(6, 10, 2), fixer(7, 10, 2)],
});
assert.deepEqual(
diagnostiquer(configuration).collisions.map(({ groupe, tablesManquantes }) => [groupe, tablesManquantes]),
[
['X', 2],
['Y', null],
],
);
});
});
describe('diagnostic : remède en tours (§ 5.6)', () => {
// 28 personnes, dix appartenances de 2 et huit de 1, G = 18 ; quatre
// tables de 7. Un participant rencontre au plus 6 personnes par tour. En
// 4 tours : min(24, 27 affiliés autres que soi) − 18 = 6. En 3 tours :
// 18 − 18 = 0.
const DIX_PAIRES_HUIT_SEULS = [
...Array.from({ length: 10 }, (_, g) => fois(2, `Grisaille ${g + 1}`)).flat(),
...Array.from({ length: 8 }, (_, g) => `Club des Merles ${g + 1}`),
];
const QUATRE_TABLES_DE_SEPT = [[10, 7], [20, 7], [30, 7], [40, 7]];
test('28 personnes en 18 appartenances, quatre tours : 6 redondances, aucune en 3 tours', () => {
assert.equal(DIX_PAIRES_HUIT_SEULS.length, 28);
const configuration = salle({
tables: QUATRE_TABLES_DE_SEPT,
appartenances: DIX_PAIRES_HUIT_SEULS,
tours: 4,
});
const diagnostic = diagnostiquer(configuration);
assert.equal(diagnostic.redondanceMinimale, 6);
assert.deepEqual(diagnostic.remedes, { toursSansRedondance: 3 });
});
// La réservation du tour 4 ne tient pas dans une configuration de 3
// tours : la sonde la retire, et celle d'un exclu aussi, au lieu de lever
// RESERVATION_TOUR. La réservation de portée « tous » couvre les trois.
test('une réservation d’un tour au-delà de R′ se retire de la sonde, sans lever', () => {
const configuration = salle({
tables: QUATRE_TABLES_DE_SEPT,
appartenances: [...DIX_PAIRES_HUIT_SEULS, 'Club des Merles 1'],
tours: 4,
reservations: [fixer(1, 10, 4), ancrer(2, 20), fixer(29, 30, 4)],
exclus: [29],
});
assert.deepEqual(diagnostiquer(configuration).remedes, { toursSansRedondance: 3 });
});
// Trois tables de 2, P1 sans appartenance, P2 et P3 de la même. En 3
// tours, P1, fixé à la table 3 à chaque tour, y retrouve P3 au tour 2 et
// P2 au tour 3 : 2 − 1 = 1. En 2 tours, P2 et P3 occupent la table 2 au
// tour 1 et P1 n'y retrouve que P3 au tour 2 : 1 − 1 = 0. Le remède, 2,
// tient aux réservations des tours 1 et 2, que la sonde garde.
test('les réservations d’un tour jusqu’à R′ restent dans la sonde', () => {
const configuration = salle({
tables: [[1, 2], [2, 2], [3, 2]],
appartenances: [null, 'Grisaille', 'Grisaille'],
tours: 3,
reservations: [
fixer(1, 3, 1), fixer(1, 3, 2), fixer(1, 3, 3),
fixer(2, 2, 1), fixer(2, 2, 2), fixer(2, 3, 3),
fixer(3, 2, 1), fixer(3, 3, 2), fixer(3, 2, 3),
],
contraintes: TOUTES,
});
const diagnostic = diagnostiquer(configuration);
assert.equal(diagnostic.redondanceMinimale, 1);
assert.deepEqual(diagnostic.remedes, { toursSansRedondance: 2 });
});
// Deux tables de 3, P1 sans appartenance ancré à la table 10, P2 et P3 de
// la même appartenance, fixés aux tables 10 et 20 aux tours 1 et 2. En 3
// tours, le tour 3 libre réunit P1 et P3 : 2 − 1 = 1. En 2 tours, P1 ne
// rencontre que P2 : 0. Le remède, 2, tient à l'ancrage, qui couvre les
// tours de la sonde, et aux réservations du tour 2, R′ lui-même.
test('l’ancrage et les réservations du tour R′ lui-même restent dans la sonde', () => {
const configuration = salle({
tables: [[10, 3], [20, 3]],
appartenances: [null, 'Grisaille', 'Grisaille'],
tours: 3,
reservations: [ancrer(1, 10), fixer(2, 10, 1), fixer(3, 20, 1), fixer(2, 10, 2), fixer(3, 20, 2)],
});
const diagnostic = diagnostiquer(configuration);
assert.equal(diagnostic.redondanceMinimale, 1);
assert.deepEqual(diagnostic.remedes, { toursSansRedondance: 2 });
});
// Deux appartenances de 14 : en un seul tour, 6 − 2 = 4 ; aucun R′ ne
// l'annule.
test('deux appartenances de 14 : aucun nombre de tours n’annule la redondance', () => {
const configuration = salle({
tables: QUATRE_TABLES_DE_SEPT,
appartenances: [...fois(14, 'Grisaille'), ...fois(14, 'Club des Merles')],
tours: 4,
});
const diagnostic = diagnostiquer(configuration);
assert.equal(diagnostic.redondanceMinimale, 22);
assert.deepEqual(diagnostic.remedes, { toursSansRedondance: null });
});
test('sans affilié, ou à redondance déjà nulle : aucun remède', () => {
const sansAffilie = salle({ tables: QUATRE_TABLES_DE_SEPT, appartenances: fois(28, null), tours: 4 });
assert.deepEqual(diagnostiquer(sansAffilie).remedes, { toursSansRedondance: null });
const dejaNulle = salle({ tables: QUATRE_TABLES_DE_SEPT, appartenances: DIX_PAIRES_HUIT_SEULS, tours: 3 });
assert.equal(diagnostiquer(dejaNulle).redondanceMinimale, 0);
assert.deepEqual(diagnostiquer(dejaNulle).remedes, { toursSansRedondance: null });
});
});
describe('diagnostic : une seule appartenance (§ 15.4)', () => {
const tables = [[10, 3], [20, 3], [30, 3], [40, 3]];
test('douze personnes d’une appartenance, séparation active : annoncée ; désactivée : non', () => {
const active = salle({ tables, appartenances: fois(12, 'Grisaille'), tours: 4, contraintes: SEPARER });
assert.equal(diagnostiquer(active).uneSeuleAppartenance, true);
const inactive = salle({ tables, appartenances: fois(12, 'Grisaille'), tours: 4, contraintes: TOUTES_SAUF_SEPARER });
assert.equal(diagnostiquer(inactive).uneSeuleAppartenance, false);
});
// Les présents sans appartenance se séparent des trois membres : la
// séparation y garde un sens.
test('la même appartenance portée par 3 des 12 : faux', () => {
const configuration = salle({
tables,
appartenances: [...fois(3, 'Grisaille'), ...fois(9, null)],
tours: 4,
contraintes: SEPARER,
});
assert.equal(diagnostiquer(configuration).uneSeuleAppartenance, false);
});
// L'appartenance du seul membre exclu ne compte pas : les présents n'en
// portent qu'une.
test('un exclu d’une autre appartenance ne compte pas', () => {
const configuration = salle({
tables,
appartenances: [...fois(11, 'Grisaille'), 'Club des Merles'],
tours: 4,
contraintes: SEPARER,
exclus: [12],
});
assert.equal(diagnostiquer(configuration).uneSeuleAppartenance, true);
});
});
describe('diagnostic : collision à chaque table (§ 7.5)', () => {
// Douze personnes en deux appartenances de six, quatre tables de 3 : une
// table reçoit au moins 12 − 9 = 3 personnes, plus que G + U = 2.
const DEUX_FOIS_SIX = [...fois(6, 'Grisaille'), ...fois(6, 'Club des Merles')];
const QUATRE_DE_TROIS = [[10, 3], [20, 3], [30, 3], [40, 3]];
test('deux appartenances de six sur quatre tables de 3 : vrai ; désactivée, faux ; une cinquième table, faux', () => {
const active = salle({ tables: QUATRE_DE_TROIS, appartenances: DEUX_FOIS_SIX, tours: 4, contraintes: SEPARER });
assert.equal(diagnostiquer(active).collisionPartout, true);
const inactive = salle({
tables: QUATRE_DE_TROIS,
appartenances: DEUX_FOIS_SIX,
tours: 4,
contraintes: TOUTES_SAUF_SEPARER,
});
assert.equal(diagnostiquer(inactive).collisionPartout, false);
// Une table de 3 de plus : chaque table peut rester vide, 12 − 12 = 0.
const cinqTables = salle({
tables: [...QUATRE_DE_TROIS, [50, 3]],
appartenances: DEUX_FOIS_SIX,
tours: 4,
contraintes: SEPARER,
});
assert.equal(diagnostiquer(cinqTables).collisionPartout, false);
});
// Trois membres de chacune des trois appartenances par table de 3 : un
// plan sans collision existe, le plan parfait.
test('la petite : faux', () => {
assert.equal(diagnostiquer(construire('petite')).collisionPartout, false);
});
// Un présent sans appartenance : 6 − 3 = 3 places au moins par table, et
// G + U = 3 personnes s'y assoient sans collision.
test('un présent sans appartenance peut rompre la preuve : faux', () => {
const configuration = salle({
tables: [[10, 3], [20, 3]],
appartenances: ['Grisaille', 'Grisaille', 'Grisaille', 'Club des Merles', 'Club des Merles', null],
tours: 2,
contraintes: SEPARER,
});
assert.equal(diagnostiquer(configuration).collisionPartout, false);
});
// Sept personnes pour six sièges : aucun plan n'assied chacun, rien ne se
// prouve. Sans table, rien non plus.
test('des places manquantes, ou aucune table : faux', () => {
const tropPetite = salle({
tables: [[10, 3], [20, 3]],
appartenances: [...fois(4, 'Grisaille'), ...fois(3, 'Club des Merles')],
tours: 2,
contraintes: SEPARER,
});
assert.equal(diagnostiquer(tropPetite).placesManquantes, 1);
assert.equal(diagnostiquer(tropPetite).collisionPartout, false);
const sansTable = salle({ tables: [], appartenances: [], tours: 2, contraintes: SEPARER });
assert.equal(diagnostiquer(sansTable).collisionPartout, false);
});
// Deux appartenances de trois, deux tables de 3, deux tours : 6 − 3 = 3
// > 2. L'énumération le confirme sans lire le diagnostic : chacun des
// C(6, 3)² = 400 plans porte une violation de séparation à chaque table de
// chaque tour.
test('deux appartenances de trois sur deux tables de 3 : vrai, et chacun des 400 plans le confirme', () => {
const configuration = salle({
tables: [[10, 3], [20, 3]],
appartenances: [...fois(3, 'Grisaille'), ...fois(3, 'Club des Merles')],
tours: 2,
contraintes: SEPARER,
});
assert.equal(diagnostiquer(configuration).collisionPartout, true);
const instance = normaliser(configuration);
const fautes = [];
const plans = enumererPlans(instance, (tableDe) => {
const cases = conflitsParTable(instance, tableDe).flat();
if (cases.length !== 4) fautes.push(`${cases.length} cases`);
for (const { table, regles } of cases) {
if (regles.every(({ regle }) => regle !== 'separerAppartenances')) {
fautes.push(`table ${table} sans collision : ${Array.from(tableDe)}`);
}
}
});
assert.equal(plans, 400);
assert.deepEqual(fautes, []);
});
});
describe('diagnostic : ancrage par table (§ 5.6)', () => {
test('trois ancrés pour trois sièges : gelée, non à moitié ; deux pour quatre : à moitié', () => {
const configuration = salle({
tables: [[10, 3], [20, 4], [30, 4]],
appartenances: fois(9, null),
tours: 2,
reservations: [ancrer(1, 10), ancrer(2, 10), ancrer(3, 10), ancrer(4, 20), ancrer(5, 20), ancrer(6, 30)],
});
assert.deepEqual(diagnostiquer(configuration).ancrage, [
{ table: 10, ancres: 3, capacite: 3, gelee: true, moitie: false },
{ table: 20, ancres: 2, capacite: 4, gelee: false, moitie: true },
{ table: 30, ancres: 1, capacite: 4, gelee: false, moitie: false },
]);
});
});

View file

@ -14,6 +14,12 @@
// sans appartenance ne sont pas collègues. Un ensemble vide rend null, jamais // 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 // 0 : le taux de diversité de qui ne rencontre aucun affilié, le minimum et la
// moyenne d'une population sans membre. // moyenne d'une population sans membre.
//
// Une seule lecture du plan décide qu'une paire est réunie, collègue ou revue,
// et qu'une personne revient : parcourirTours. mesurer en tire chacun de ses
// chiffres, et conflitsParTable (conflits.js) le détail par table, si bien
// que la somme des violations par table égale le compte de la mesure
// (§ 13.2).
import { import {
RESERVE, RESERVE,
SANS_GROUPE, SANS_GROUPE,
@ -41,10 +47,15 @@ import {
* @property {number[]} redondance r(p) = |F(p)| − A(p) * @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|null)[]} diversite d(p) = A(p) / |F(p)|, null si |F(p)| = 0
* @property {number[]} toursAssis * @property {number[]} toursAssis
* @property {number[]} retoursChoisis par personne * @property {number[]} retoursChoisis par personne : ses occurrences
* @property {number[]} retoursImposes par personne * parmi les revenus de parcourirTours
* @property {number[]} retoursImposes par personne : ses retours moins
* ses retours choisis
* @property {TroisAgregats} aggRencontres * @property {TroisAgregats} aggRencontres
* @property {TroisAgregats} aggRedondance * @property {TroisAgregats} aggRedondance
* @property {TroisAgregats} aggAppartenancesVues A(p)
* @property {TroisAgregats} aggDiversite d(p), sur les seules personnes qui
* rencontrent un affilié
* @property {number} totalRedondance Σ_p r(p), le seul total de la * @property {number} totalRedondance Σ_p r(p), le seul total de la
* redondance : la recherche et le * redondance : la recherche et le
* classement le lisent * classement le lisent
@ -53,7 +64,8 @@ import {
* @property {number} excedentCollisions cumulées − distinctes * @property {number} excedentCollisions cumulées − distinctes
* @property {{choisies: number, imposees: number}} rencontresRepetees * @property {{choisies: number, imposees: number}} rencontresRepetees
* paires réunies ≥ 2 fois ; « imposée » quand chaque rencontre de la * 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à * paire a lieu à une table où les deux sont réservés ce tour-là ;
* choisies, les paires distinctes des revues de parcourirTours
* @property {number} maxRencontresPaire sur toutes les paires ; 0 si aucune * @property {number} maxRencontresPaire sur toutes les paires ; 0 si aucune
* @property {number} totalRetoursChoisis * @property {number} totalRetoursChoisis
* @property {number} totalRetoursImposes * @property {number} totalRetoursImposes
@ -70,57 +82,122 @@ const UINT16_MAX = 0xffff;
// (N − 1) + (N − 2) + … + (N − a) paires des lignes qui la précèdent. // (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); 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 * Le plan lu tour par tour, puis table par table, par index croissants : le
// moins a lieu hors d'une table où les deux sont réservés ce tour-là. Une * seul code qui décide qu'une paire est réunie, de même appartenance ou
// paire se réunit au plus une fois par tour : R borne chaque compte, qu'un * revue, et qu'une personne revient. mesurer en tire chacun de ses chiffres,
// Uint16Array tient tant que R ≤ 65 535. * conflitsParTable (conflits.js) le détail par table.
function compterRencontres({ N, T, R, fixe }, tableDe) { *
const paires = (N * (N - 1)) / 2; * parTour[r][t], en index de personnes, chaque paire [a, b] avec a < b,
const compte = R <= UINT16_MAX ? new Uint16Array(paires) : new Uint32Array(paires); * chaque liste croissante — paires dans l'ordre de a, puis de b :
const choisie = new Uint8Array(paires); * - collegues : paires de même appartenance assises à t au tour r ;
* - revues : paires assises à t au tour r et réunies à un tour antérieur,
* sauf quand chacune de leurs rencontres jusqu'à r a lieu à une table où
* les deux sont réservés ce tour-là ;
* - revenus : personnes assises à t au tour r et à un tour antérieur, sauf
* quand elles y sont réservées au tour r et lors d'une visite antérieure
* à t.
* parPaire : les rencontres de chaque paire, rangées par rangPaire ; une
* paire se réunit au plus une fois par tour, R borne chaque compte, qu'un
* Uint16Array tient tant que R ≤ 65 535. toursAssis, et retours — visites
* d'une table déjà visitée, imposées comprises —, par personne.
*
* Ne modifie ni l'instance ni le plan ; lève ce que lève exigerPlanIndexe.
*
* @param {import('./types.js').Instance} instance
* @param {ArrayLike<number>} tableDe
* @returns {{parTour: Array<Array<{collegues: number[][], revues: number[][], revenus: number[]}>>,
* parPaire: Uint16Array|Uint32Array, toursAssis: number[], retours: number[]}}
*/
export function parcourirTours(instance, tableDe) {
exigerPlanIndexe(instance, tableDe);
const { N, T, R, fixe, groupe } = instance;
// Personne par personne, ses tours dans l'ordre : visites[t] compte ses
// passages à la table t, reserveeAvant[t] note qu'un passage antérieur y
// était réservé. revenu[p × R + r] marque la visite répétée que la
// personne choisit. Le second parcours de ses tours remet à zéro les
// tables qu'elle a visitées : la personne suivante les trouve nulles.
const toursAssis = new Array(N).fill(0);
const retours = new Array(N).fill(0);
const revenu = new Uint8Array(N * R);
const visites = new Int32Array(T);
const reserveeAvant = new Uint8Array(T);
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) continue;
toursAssis[p] += 1;
const reservee = fixe[p * R + r] === t;
if (visites[t] > 0) {
retours[p] += 1;
if (!reservee || reserveeAvant[t] === 0) revenu[p * R + r] = 1;
}
visites[t] += 1;
if (reservee) reserveeAvant[t] = 1;
}
for (let r = 0; r < R; r += 1) {
const t = tableDe[p * R + r];
if (t === RESERVE) continue;
visites[t] = 0;
reserveeAvant[t] = 0;
}
}
// Tour par tour : 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. libre[k] note qu'une
// rencontre de la paire, jusqu'au tour courant compris, a eu lieu hors
// d'une table où les deux sont réservés.
const nombrePaires = (N * (N - 1)) / 2;
const parPaire = R <= UINT16_MAX ? new Uint16Array(nombrePaires) : new Uint32Array(nombrePaires);
const libre = new Uint8Array(nombrePaires);
const parTour = [];
for (let r = 0; r < R; r += 1) { 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 }, () => []); const assis = Array.from({ length: T }, () => []);
for (let p = 0; p < N; p += 1) { for (let p = 0; p < N; p += 1) {
const t = tableDe[p * R + r]; const t = tableDe[p * R + r];
if (t !== RESERVE) assis[t].push(p); if (t !== RESERVE) assis[t].push(p);
} }
const tables = [];
for (let t = 0; t < T; t += 1) { for (let t = 0; t < T; t += 1) {
const liste = assis[t]; const liste = assis[t];
const collegues = [];
const revues = [];
const revenus = [];
for (let i = 0; i < liste.length; i += 1) { for (let i = 0; i < liste.length; i += 1) {
const a = liste[i]; const a = liste[i];
if (revenu[a * R + r] === 1) revenus.push(a);
const aReserve = fixe[a * R + r] === t; const aReserve = fixe[a * R + r] === t;
const g = groupe[a];
for (let j = i + 1; j < liste.length; j += 1) { for (let j = i + 1; j < liste.length; j += 1) {
const b = liste[j]; const b = liste[j];
const k = rangPaire(N, a, b); const k = rangPaire(N, a, b);
compte[k] += 1; parPaire[k] += 1;
if (!aReserve || fixe[b * R + r] !== t) choisie[k] = 1; if (!aReserve || fixe[b * R + r] !== t) libre[k] = 1;
if (g !== SANS_GROUPE && g === groupe[b]) collegues.push([a, b]);
if (parPaire[k] >= 2 && libre[k] === 1) revues.push([a, b]);
} }
} }
tables.push({ collegues, revues, revenus });
} }
parTour.push(tables);
} }
return { compte, choisie }; return { parTour, parPaire, toursAssis, retours };
} }
// Lecture du triangle des paires, dans l'ordre de rangPaire. Par personne : // 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 // |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 // affilié du groupe g. Sur toutes les paires : les paires réunies deux fois
// porte n collisions cumulées et une paire distincte. Sur toutes les paires : // au moins, et le plus grand compte.
// les rencontres répétées, choisies ou imposées, et le plus grand compte. function lirePaires({ N, groupe, groupes }, parPaire) {
function lirePaires({ N, groupe, groupes }, { compte, choisie }) {
const G = groupes.length; const G = groupes.length;
const rencontres = new Int32Array(N); const rencontres = new Int32Array(N);
const affilies = new Int32Array(N); const affilies = new Int32Array(N);
const vues = new Int32Array(N); const vues = new Int32Array(N);
const vu = new Uint8Array(N * G); const vu = new Uint8Array(N * G);
const collisions = new Array(G).fill(0); let repetees = 0;
const distinctes = new Array(G).fill(0);
let choisies = 0;
let imposees = 0;
let maxRencontresPaire = 0; let maxRencontresPaire = 0;
// p rencontre q : q compte dans met(p) ; un affilié compte aussi dans F(p), // p rencontre q : q compte dans met(p) ; un affilié compte aussi dans F(p),
@ -140,70 +217,52 @@ function lirePaires({ N, groupe, groupes }, { compte, choisie }) {
let k = 0; let k = 0;
for (let a = 0; a < N; a += 1) { for (let a = 0; a < N; a += 1) {
for (let b = a + 1; b < N; b += 1, k += 1) { for (let b = a + 1; b < N; b += 1, k += 1) {
const n = compte[k]; const n = parPaire[k];
if (n === 0) continue; if (n === 0) continue;
rencontrer(a, b); rencontrer(a, b);
rencontrer(b, a); rencontrer(b, a);
if (n > maxRencontresPaire) maxRencontresPaire = n; if (n > maxRencontresPaire) maxRencontresPaire = n;
if (n >= 2) { if (n >= 2) repetees += 1;
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 { return { rencontres, affilies, vues, repetees, maxRencontresPaire };
rencontres,
affilies,
vues,
collisions,
distinctes,
rencontresRepetees: { choisies, imposees },
maxRencontresPaire,
};
} }
// Tours assis et retours de chaque personne (§ 5.4). Pour une table visitée // Lecture des listes de parcourirTours. Une paire de collègues réunie n fois
// m fois, dont f à un tour où la personne y est réservée : max(0, f − 1) // porte n collisions cumulées et compte une fois parmi les paires
// retours imposés et (m − 1) − imposés retours choisis. visites et reservees // distinctes de son groupe ; une paire revue compte une fois parmi les
// comptent par table les tours de la personne en cours ; le second parcours // rencontres répétées choisies, à quelque tour qu'elle le soit ; chaque
// de ses tours solde chaque table à sa première occurrence et remet ses // occurrence d'une personne revenue est un retour choisi.
// compteurs à zéro, si bien que la personne suivante les trouve nuls. function lireTours({ N, groupe, groupes }, parTour) {
function compterRetours({ N, T, R, fixe }, tableDe) { const nombrePaires = (N * (N - 1)) / 2;
const toursAssis = []; const collisions = new Array(groupes.length).fill(0);
const retoursChoisis = []; const distinctes = new Array(groupes.length).fill(0);
const retoursImposes = []; const collegueVu = new Uint8Array(nombrePaires);
const visites = new Int32Array(T); const revueVue = new Uint8Array(nombrePaires);
const reservees = new Int32Array(T); const retoursChoisis = new Array(N).fill(0);
for (let p = 0; p < N; p += 1) { let choisies = 0;
let assis = 0; for (const tables of parTour) {
for (let r = 0; r < R; r += 1) { for (const { collegues, revues, revenus } of tables) {
const t = tableDe[p * R + r]; for (const [a, b] of collegues) {
if (t === RESERVE) continue; const g = groupe[a];
assis += 1; const k = rangPaire(N, a, b);
visites[t] += 1; collisions[g] += 1;
if (fixe[p * R + r] === t) reservees[t] += 1; if (collegueVu[k] === 0) {
collegueVu[k] = 1;
distinctes[g] += 1;
}
}
for (const [a, b] of revues) {
const k = rangPaire(N, a, b);
if (revueVue[k] === 0) {
revueVue[k] = 1;
choisies += 1;
}
}
for (const p of revenus) retoursChoisis[p] += 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 }; return { collisions, distinctes, choisies, retoursChoisis };
} }
// Population sans membre. min reste null tant qu'aucune valeur n'y entre : // Population sans membre. min reste null tant qu'aucune valeur n'y entre :
@ -224,10 +283,14 @@ const conclure = ({ min, somme, effectif }) => ({
effectif, effectif,
}); });
// La population, une valeur de plus quand elle en est une : null n'y entre
// pas, et la personne qui le porte sort de l'effectif.
const ajouterSiDefinie = (cumul, valeur) => (valeur === null ? cumul : ajouter(cumul, valeur));
// Minimum, moyenne et effectif de valeurs[p] sur les trois populations du // Minimum, moyenne et effectif de valeurs[p] sur les trois populations du
// § 5.4, que replierParPopulation forme. // § 5.4, que replierParPopulation forme ; replier y range chaque valeur.
function troisAgregats(valeurs, statut) { function troisAgregats(valeurs, statut, replier = ajouter) {
const { tous, mobiles, ancres } = replierParPopulation(valeurs, statut, VIDE, ajouter); const { tous, mobiles, ancres } = replierParPopulation(valeurs, statut, VIDE, replier);
return { tous: conclure(tous), mobiles: conclure(mobiles), ancres: conclure(ancres) }; return { tous: conclure(tous), mobiles: conclure(mobiles), ancres: conclure(ancres) };
} }
@ -245,16 +308,17 @@ const somme = (valeurs) => valeurs.reduce((total, valeur) => total + valeur, 0);
* @returns {Mesures} * @returns {Mesures}
*/ */
export function mesurer(instance, tableDe) { export function mesurer(instance, tableDe) {
exigerPlanIndexe(instance, tableDe);
const { N, groupe, groupes, statut } = instance; const { N, groupe, groupes, statut } = instance;
const paires = lirePaires(instance, compterRencontres(instance, tableDe)); const { parTour, parPaire, toursAssis, retours } = parcourirTours(instance, tableDe);
const { collisions, distinctes } = paires; const paires = lirePaires(instance, parPaire);
const { collisions, distinctes, choisies, retoursChoisis } = lireTours(instance, parTour);
const rencontres = Array.from(paires.rencontres); const rencontres = Array.from(paires.rencontres);
const affilies = Array.from(paires.affilies); const affilies = Array.from(paires.affilies);
const appartenancesVues = Array.from(paires.vues); const appartenancesVues = Array.from(paires.vues);
const redondance = affilies.map((f, p) => f - appartenancesVues[p]); const redondance = affilies.map((f, p) => f - appartenancesVues[p]);
const diversite = affilies.map((f, p) => (f === 0 ? null : appartenancesVues[p] / f)); const diversite = affilies.map((f, p) => (f === 0 ? null : appartenancesVues[p] / f));
const retoursImposes = retours.map((total, p) => total - retoursChoisis[p]);
const effectifs = new Array(groupes.length).fill(0); const effectifs = new Array(groupes.length).fill(0);
for (let p = 0; p < N; p += 1) { for (let p = 0; p < N; p += 1) {
@ -270,8 +334,6 @@ export function mesurer(instance, tableDe) {
const collisionsCumulees = somme(collisions); const collisionsCumulees = somme(collisions);
const pairesDistinctes = somme(distinctes); const pairesDistinctes = somme(distinctes);
const { toursAssis, retoursChoisis, retoursImposes } = compterRetours(instance, tableDe);
return { return {
rencontres, rencontres,
affilies, affilies,
@ -283,11 +345,13 @@ export function mesurer(instance, tableDe) {
retoursImposes, retoursImposes,
aggRencontres: troisAgregats(rencontres, statut), aggRencontres: troisAgregats(rencontres, statut),
aggRedondance: troisAgregats(redondance, statut), aggRedondance: troisAgregats(redondance, statut),
aggAppartenancesVues: troisAgregats(appartenancesVues, statut),
aggDiversite: troisAgregats(diversite, statut, ajouterSiDefinie),
totalRedondance: somme(redondance), totalRedondance: somme(redondance),
collisionsCumulees, collisionsCumulees,
pairesDistinctes, pairesDistinctes,
excedentCollisions: collisionsCumulees - pairesDistinctes, excedentCollisions: collisionsCumulees - pairesDistinctes,
rencontresRepetees: paires.rencontresRepetees, rencontresRepetees: { choisies, imposees: paires.repetees - choisies },
maxRencontresPaire: paires.maxRencontresPaire, maxRencontresPaire: paires.maxRencontresPaire,
totalRetoursChoisis: somme(retoursChoisis), totalRetoursChoisis: somme(retoursChoisis),
totalRetoursImposes: somme(retoursImposes), totalRetoursImposes: somme(retoursImposes),

View file

@ -9,7 +9,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js'; import { describe, test } from '../../test/lanceur.js';
import { indexerPlan, normaliser } from './configuration.js'; import { indexerPlan, normaliser } from './configuration.js';
import { mesurer } from './indicateurs.js'; import { mesurer, parcourirTours } from './indicateurs.js';
const SANS_CONTRAINTE = { const SANS_CONTRAINTE = {
separerAppartenances: false, separerAppartenances: false,
@ -135,6 +135,18 @@ describe('mesurer', () => {
mobiles: { min: 6, moyenne: 6, effectif: 12 }, mobiles: { min: 6, moyenne: 6, effectif: 12 },
ancres: AUCUN, ancres: AUCUN,
}, },
// Chacun croise deux appartenances, la sienne exclue : A(p) = 2 pour
// huit affiliés rencontrés, d(p) = 2 / 8.
aggAppartenancesVues: {
tous: { min: 2, moyenne: 2, effectif: 12 },
mobiles: { min: 2, moyenne: 2, effectif: 12 },
ancres: AUCUN,
},
aggDiversite: {
tous: { min: 0.25, moyenne: 0.25, effectif: 12 },
mobiles: { min: 0.25, moyenne: 0.25, effectif: 12 },
ancres: AUCUN,
},
totalRedondance: 72, totalRedondance: 72,
collisionsCumulees: 0, collisionsCumulees: 0,
pairesDistinctes: 0, pairesDistinctes: 0,
@ -268,6 +280,46 @@ describe('mesurer', () => {
mobiles: { min: 0, moyenne: 0, effectif: 3 }, mobiles: { min: 0, moyenne: 0, effectif: 3 },
ancres: AUCUN, ancres: AUCUN,
}); });
// A(p) se replie sur chacun ; d(p) sur les seuls qui rencontrent un
// affilié : 3 sort de l'effectif, et son null n'entre ni dans le minimum
// ni dans la moyenne.
assert.deepEqual(mesures.aggAppartenancesVues, {
tous: { min: 0, moyenne: 2 / 3, effectif: 3 },
mobiles: { min: 0, moyenne: 2 / 3, effectif: 3 },
ancres: AUCUN,
});
assert.deepEqual(mesures.aggDiversite, {
tous: { min: 1, moyenne: 1, effectif: 2 },
mobiles: { min: 1, moyenne: 1, effectif: 2 },
ancres: AUCUN,
});
});
test("le taux de diversité se replie par population : des mobiles qui ne croisent aucun affilié laissent leur ligne vide", () => {
// 1 et 2, sans appartenance, sont ancrés à la table 1 ; 3 et 4, de X et
// de Y, mobiles, la visitent tour à tour. 1 et 2 rencontrent chacun 3 et
// 4 : d = 2 / 2. 3 rencontre 1 et 2, sans appartenance, au tour 1, puis
// personne : il sort de l'effectif ; de même 4.
const mesures = mesurerPlan({
appartenance: [null, null, 'X', 'Y'],
capacite: 3,
tours: [
[[1, 2, 3], [4]],
[[1, 2, 4], [3]],
],
reservations: [tous(1, 1), tous(2, 1)],
});
assert.deepEqual(mesures.diversite, [1, 1, null, null]);
assert.deepEqual(mesures.aggDiversite, {
tous: { min: 1, moyenne: 1, effectif: 2 },
mobiles: AUCUN,
ancres: { min: 1, moyenne: 1, effectif: 2 },
});
assert.deepEqual(mesures.aggAppartenancesVues, {
tous: { min: 0, moyenne: 1, effectif: 4 },
mobiles: { min: 0, moyenne: 0, effectif: 2 },
ancres: { min: 2, moyenne: 2, effectif: 2 },
});
}); });
test("la réserve n'est pas une table : seuls les tours assis comptent", () => { test("la réserve n'est pas une table : seuls les tours assis comptent", () => {
@ -521,6 +573,8 @@ describe('mesurer', () => {
retoursImposes: [], retoursImposes: [],
aggRencontres: { tous: AUCUN, mobiles: AUCUN, ancres: AUCUN }, aggRencontres: { tous: AUCUN, mobiles: AUCUN, ancres: AUCUN },
aggRedondance: { tous: AUCUN, mobiles: AUCUN, ancres: AUCUN }, aggRedondance: { tous: AUCUN, mobiles: AUCUN, ancres: AUCUN },
aggAppartenancesVues: { tous: AUCUN, mobiles: AUCUN, ancres: AUCUN },
aggDiversite: { tous: AUCUN, mobiles: AUCUN, ancres: AUCUN },
totalRedondance: 0, totalRedondance: 0,
collisionsCumulees: 0, collisionsCumulees: 0,
pairesDistinctes: 0, pairesDistinctes: 0,
@ -577,3 +631,84 @@ describe('mesurer', () => {
); );
}); });
}); });
// Quatre personnes, 1 et 2 de X, deux tables de 3, trois tours ; 4 attend en
// réserve au tour 3. Les paires se rangent par rangPaire : (0, 1), (0, 2),
// (0, 3), (1, 2), (1, 3), (2, 3), en index de personnes.
const PARCOURS = {
appartenance: ['X', 'X', null, null],
capacite: 3,
tours: [
[[1, 2, 3], [4]],
[[1, 2], [3, 4]],
[[1, 3], [2]],
],
reserves: [[], [], [4]],
};
describe('parcourirTours', () => {
test('chaque table de chaque tour : collègues, paires revues, personnes revenues, en index croissants', () => {
// Tour 1 : (0, 1) collègues, rien de revu, personne ne revient. Tour 2 :
// (0, 1) collègues et revus ; 0 et 1 reviennent à la table d'index 0, 3
// à celle d'index 1, que 2 découvre. Tour 3 : (0, 2) revus, réunis
// librement au tour 1 ; 0 revient une deuxième fois, 2 revient ; 1
// découvre la table d'index 1.
const { instance, tableDe } = preparer(PARCOURS);
const vide = { collegues: [], revues: [], revenus: [] };
assert.deepEqual(parcourirTours(instance, tableDe), {
parTour: [
[{ collegues: [[0, 1]], revues: [], revenus: [] }, vide],
[
{ collegues: [[0, 1]], revues: [[0, 1]], revenus: [0, 1] },
{ collegues: [], revues: [], revenus: [3] },
],
[{ collegues: [], revues: [[0, 2]], revenus: [0, 2] }, vide],
],
parPaire: Uint16Array.of(2, 2, 0, 1, 0, 1),
toursAssis: [3, 3, 3, 2],
retours: [2, 1, 1, 1],
});
});
test('une rencontre et une visite que les réservations imposent ne se signalent pas ; elles restent comptées', () => {
// 1 et 2 sont réservés à la table 1 aux tours 1 et 2 : leur rencontre du
// tour 2 ne répète qu'une rencontre réservée, et chacun y revient
// réservé, comme à sa visite du tour 1. Au tour 3, 1 y revient sans
// réservation : il est revenu. parPaire et retours comptent tout,
// imposé compris.
const { instance, tableDe } = preparer({
...PARCOURS,
reservations: [auTour(1, 1, 1), auTour(2, 1, 1), auTour(1, 1, 2), auTour(2, 1, 2)],
});
const { parTour, parPaire, retours } = parcourirTours(instance, tableDe);
assert.deepEqual(parTour[1][0], { collegues: [[0, 1]], revues: [], revenus: [] });
assert.deepEqual(parTour[2][0], { collegues: [], revues: [[0, 2]], revenus: [0, 2] });
assert.deepEqual(Array.from(parPaire), [2, 2, 0, 1, 0, 1]);
assert.deepEqual(retours, [2, 1, 1, 1]);
});
test('une table, ses tours, et aucune personne présente : chaque case est vide', () => {
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,
});
const vide = { collegues: [], revues: [], revenus: [] };
assert.deepEqual(parcourirTours(instance, new Int32Array(0)), {
parTour: [[vide], [vide]],
parPaire: new Uint16Array(0),
toursAssis: [],
retours: [],
});
});
test("lève ce que lève exigerPlanIndexe, et ne modifie ni l'instance ni le plan", () => {
const { instance, tableDe } = preparer(PARCOURS);
assert.throws(() => parcourirTours(instance, tableDe.subarray(1)), RangeError);
const avant = { tableDe: tableDe.slice(), fixe: instance.fixe.slice(), groupe: instance.groupe.slice() };
parcourirTours(instance, tableDe);
assert.deepEqual({ tableDe, fixe: instance.fixe, groupe: instance.groupe }, avant);
});
});

View file

@ -24,7 +24,9 @@
// d'une proposition, la place d'une autre à une autre table touche les deux // d'une proposition, la place d'une autre à une autre table touche les deux
// listes de ces tables et rien d'autre : une entrée de chacune quand chaque // listes de ces tables et rien d'autre : une entrée de chacune quand chaque
// personne prend le rang de l'autre, ce que garde l'ordre des sièges quand // personne prend le rang de l'autre, ce que garde l'ordre des sièges quand
// ils sont attribués. Sans attribution, chaque liste se trie par // ils sont attribués ; une personne qui quitte sa chaise sans être
// remplacée y laisse null, le marqueur d'une chaise vide, une pose au rang
// de la chaise. Sans attribution, chaque liste se trie par
// identifiant, et l'entrée qui change de rang décale celles qu'elle // identifiant, et l'entrée qui change de rang décale celles qu'elle
// franchit ; une liste d'identifiants de même longueur se pose alors // franchit ; une liste d'identifiants de même longueur se pose alors
// entière dès que ses poses rang par rang pèsent plus que cette pose, et // entière dès que ses poses rang par rang pèsent plus que cette pose, et
@ -64,12 +66,15 @@ import { ErreurStockage } from './erreurs.js';
* @typedef {OperationPoser|OperationRetirer} Operation * @typedef {OperationPoser|OperationRetirer} Operation
*/ */
// Règles de la charge, du retenu et d'un identifiant dans le schéma du // Règles de la charge, du retenu, d'un identifiant et de l'occupant d'une
// fichier d'état. // liste de table — un identifiant, ou null pour une chaise vide — dans le
// schéma du fichier d'état.
const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1]; const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
const CHARGE = regleDuChamp(SCHEMA, 'charge'); const CHARGE = regleDuChamp(SCHEMA, 'charge');
const RETENU = regleDuChamp(CHARGE, 'retenu'); const RETENU = regleDuChamp(CHARGE, 'retenu');
const IDENTIFIANT = regleDuChamp(regleDuChamp(CHARGE, 'propositions').element, 'id'); const PROPOSITION = regleDuChamp(CHARGE, 'propositions').element;
const IDENTIFIANT = regleDuChamp(PROPOSITION, 'id');
const OCCUPANT = regleDuChamp(regleDuChamp(PROPOSITION, 'placement').element, 'sieges').element.element;
// Vrai pour un objet qui n'est ni null ni une liste. // Vrai pour un objet qui n'est ni null ni une liste.
const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur); const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
@ -103,9 +108,9 @@ const poids = (operations) => JSON.stringify(operations).length;
// objets se comparent champ par champ, dans l'ordre du schéma. Deux listes // objets se comparent champ par champ, dans l'ordre du schéma. Deux listes
// d'enregistrements à identifiants distincts se comparent par identifiant ; // d'enregistrements à identifiants distincts se comparent par identifiant ;
// deux autres listes, de longueurs différentes, posent b entière, et de même // deux autres listes, de longueurs différentes, posent b entière, et de même
// longueur se comparent rang par rang — deux listes d'identifiants // longueur se comparent rang par rang — deux listes d'identifiants ou
// exceptées, qui posent b entière quand leurs poses rang par rang pèsent // d'occupants exceptées, qui posent b entière quand leurs poses rang par
// plus que la sienne. // rang pèsent plus que la sienne.
function comparer(a, b, regle, chemin, operations) { function comparer(a, b, regle, chemin, operations) {
if (a === null || b === null || (regle.genre !== 'objet' && regle.genre !== 'liste')) { if (a === null || b === null || (regle.genre !== 'objet' && regle.genre !== 'liste')) {
if (a !== b) operations.push({ op: 'poser', chemin, valeur: b }); if (a !== b) operations.push({ op: 'poser', chemin, valeur: b });
@ -117,23 +122,25 @@ function comparer(a, b, regle, chemin, operations) {
comparerParIdentifiant(a, b, regle.element, chemin, operations); comparerParIdentifiant(a, b, regle.element, chemin, operations);
} else if (a.length !== b.length) { } else if (a.length !== b.length) {
operations.push({ op: 'poser', chemin, valeur: b }); operations.push({ op: 'poser', chemin, valeur: b });
} else if (regle.element === IDENTIFIANT) { } else if (regle.element === IDENTIFIANT || regle.element === OCCUPANT) {
comparerIdentifiants(a, b, chemin, operations); comparerIdentifiants(a, b, regle.element, chemin, operations);
} else { } else {
a.forEach((element, rang) => comparer(element, b[rang], regle.element, [...chemin, rang], operations)); a.forEach((element, rang) => comparer(element, b[rang], regle.element, [...chemin, rang], operations));
} }
} }
// Ajoute à operations ce qui mène de a à b, deux listes d'identifiants de // Ajoute à operations ce qui mène de a à b, deux listes d'identifiants de
// même longueur, au bout de chemin : une pose par rang qui change, ou b // même longueur, d'élément de règle regleElement, au bout de chemin : une
// entière quand ces poses pèsent plus que la sienne. Une liste triée par // pose par rang qui change, ou b entière quand ces poses pèsent plus que la
// identifiant — une liste de table sans attribution, une réserve, les // sienne. Une liste triée par identifiant — une liste de table sans
// participants d'un plan — décale d'un rang chaque entrée qu'un identifiant // attribution, une réserve, les participants d'un plan — décale d'un rang
// remplacé franchit, et ses poses rang par rang se multiplient ; ce qui // chaque entrée qu'un identifiant remplacé franchit, et ses poses rang par
// s'en écrit ne pèse jamais plus que la liste posée entière. // rang se multiplient ; ce qui s'en écrit ne pèse jamais plus que la liste
function comparerIdentifiants(a, b, chemin, operations) { // posée entière. Sous des sièges attribués, une chaise libérée ou reprise
// ne change que son rang : une pose, de valeur null ou de l'occupant.
function comparerIdentifiants(a, b, regleElement, chemin, operations) {
const parRang = []; const parRang = [];
a.forEach((id, rang) => comparer(id, b[rang], IDENTIFIANT, [...chemin, rang], parRang)); a.forEach((id, rang) => comparer(id, b[rang], regleElement, [...chemin, rang], parRang));
const entiere = { op: 'poser', chemin, valeur: b }; const entiere = { op: 'poser', chemin, valeur: b };
operations.push(...(poids(parRang) > poids([entiere]) ? [entiere] : parRang)); operations.push(...(poids(parRang) > poids([entiere]) ? [entiere] : parRang));
} }

View file

@ -1662,3 +1662,90 @@ describe("appliquer : un correctif qui ne s'applique pas lève CORRECTIF avec le
} }
}); });
}); });
describe('le marqueur de chaise vide : correctifs et forme canonique (§ 8.9)', () => {
// La petite démonstration, sa proposition 1 à sièges attribués, retenue
// telle quelle : au tour 1, la table 1 assied 9, 5 et 1, dans cet ordre
// de sièges. Une charge neuve à chaque appel.
function retenuAttribue() {
const charge = chargeDe('petite', [{ id: 1, siegesAttribues: true }]);
charge.propositions[0].placement[0].sieges[0] = [9, 5, 1];
charge.retenu = retenuDe(charge.propositions[0]);
charge.evenement.etat = 'retenu';
return charge;
}
// retenuAttribue, la personne 5 retirée du tour 1 du retenu : sa chaise,
// le siège 2 de la table 1, est libre, et elle attend en réserve.
function chaiseLiberee() {
const charge = retenuAttribue();
charge.retenu.placement[0].sieges[0] = [9, null, 1];
charge.retenu.placement[0].reserve = [5];
return charge;
}
test("libérer une chaise du retenu : une seule pose dans la liste, valeur null, au rang de la chaise ; appliquer rend la charge d'après, que canoniser garde dans l'ordre des sièges", () => {
const a = geler(retenuAttribue());
const b = geler(chaiseLiberee());
assert.deepStrictEqual(fautes(b.retenu), []);
const correctif = difference(a, b);
assert.deepStrictEqual(correctif, [
{ op: 'poser', chemin: ['retenu', 'placement', 0, 'sieges', 0, 1], valeur: null },
{ op: 'poser', chemin: ['retenu', 'placement', 0, 'reserve'], valeur: [5] },
]);
const rendue = appliquer(a, correctif);
assert.deepStrictEqual(rendue, canoniser(b));
assert.deepStrictEqual(rendue.retenu.placement[0].sieges[0], [9, null, 1]);
assert.equal(serialiserCharge(rendue), serialiserCharge(b));
assert.deepStrictEqual(analyser(serialiser(rendue, ENTETE)).charge.retenu, rendue.retenu);
// Le retour : la chaise reprend son occupant, par une pose au même rang.
const retour = difference(b, a);
assert.deepStrictEqual(retour, [
{ op: 'poser', chemin: ['retenu', 'placement', 0, 'sieges', 0, 1], valeur: 5 },
{ op: 'poser', chemin: ['retenu', 'placement', 0, 'reserve'], valeur: [] },
]);
assert.deepStrictEqual(appliquer(b, retour), canoniser(a));
});
test("dans une proposition aussi, une chaise libérée est une pose au rang de la chaise ; une liste qui change à chaque rang se pose entière, chaises vides comprises", () => {
const a = geler(retenuAttribue());
const b = retenuAttribue();
b.propositions[0].placement[0].sieges[0] = [9, null, 1];
b.propositions[0].placement[0].reserve = [5];
assert.deepStrictEqual(difference(a, geler(b)), [
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0, 'sieges', 0, 1], valeur: null },
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0, 'reserve'], valeur: [5] },
]);
const c = chaiseLiberee();
c.retenu.placement[0].sieges[0] = [1, null, 9];
const correctif = difference(a, geler(c));
assert.deepStrictEqual(correctif, [
{ op: 'poser', chemin: ['retenu', 'placement', 0, 'sieges', 0], valeur: [1, null, 9] },
{ op: 'poser', chemin: ['retenu', 'placement', 0, 'reserve'], valeur: [5] },
]);
assert.deepStrictEqual(appliquer(a, correctif), canoniser(c));
});
test("appliquer admet null dans une liste de table, comme la règle, et le refuse ailleurs dans un placement : réserve, tables, participants, capacités", () => {
const base = geler(chaiseLiberee());
const admises = [
['retenu', 'placement', 1, 'sieges', 2, 1],
['propositions', { id: 1 }, 'placement', 2, 'sieges', 0, 1],
];
for (const chemin of admises) {
assert.equal(refusDe(base, [{ op: 'poser', chemin, valeur: null }]), 'aucun refus', JSON.stringify(chemin));
}
const refusees = [
['retenu', 'placement', 0, 'reserve', 0],
['retenu', 'tables', 0],
['retenu', 'participants', 0],
['retenu', 'capacites', 0],
['propositions', { id: 1 }, 'placement', 1, 'sieges', 0],
['propositions', { id: 1 }, 'participants', 3],
];
assert.ok(refusees.length > 0);
for (const chemin of refusees) {
assert.deepEqual(refusDe(base, [{ op: 'poser', chemin, valeur: null }]), { rang: 0 }, JSON.stringify(chemin));
}
});
});

View file

@ -82,6 +82,12 @@ const NOMBRE = regle('nombre');
// reste ainsi loin de 2^53, où l'analyse ne le relirait plus. Au-delà, la // reste ainsi loin de 2^53, où l'analyse ne le relirait plus. Au-delà, la
// valeur sort de sa règle. // valeur sort de sa règle.
const IDENTIFIANT = entier(1, 2 ** 31 - 1); const IDENTIFIANT = entier(1, 2 ** 31 - 1);
// L'occupant d'un siège dans une liste de table d'un placement :
// l'identifiant d'une personne, ou null, le marqueur d'une chaise vide
// (§ 8.9). La règle admet null à toute place d'une telle liste ; le
// contrôle des placements (placements.js) ne l'admet que sous des sièges
// attribués, et jamais en fin de liste.
const OCCUPANT = entier(1, 2 ** 31 - 1, { nul: true });
const DATE_OU_NUL = regle('date', { nul: true }); const DATE_OU_NUL = regle('date', { nul: true });
// Ordres des listes de la charge (§ 8.8, § 8.9). canonique.js range les // Ordres des listes de la charge (§ 8.8, § 8.9). canonique.js range les
@ -196,15 +202,18 @@ const TITRE = objet([
]); ]);
// Le placement d'une proposition ou du retenu (§ 8.9) : un tour par élément, // Le placement d'une proposition ou du retenu (§ 8.9) : un tour par élément,
// chacun sur sa ligne. sieges porte une liste d'identifiants par table // chacun sur sa ligne. sieges porte une liste d'occupants par table
// déclarée, dans l'ordre des sièges. Quand l'objet qui porte le placement a // déclarée, dans l'ordre des sièges. Quand l'objet qui porte le placement a
// siegesAttribues faux, cet ordre ne porte rien, et chaque liste se trie par // siegesAttribues faux, cet ordre ne porte rien, et chaque liste se trie par
// identifiant croissant ; vrai, il est celui des sièges et se garde. reserve // identifiant croissant ; vrai, il est celui des sièges et se garde : le
// siège est le rang dans la liste, et une chaise vide avant le dernier
// occupant, siège 1 compris, y porte null. Une liste qui ne finit jamais
// par null a ainsi une seule écriture, que le tri ne touche pas. reserve
// porte ceux qui ne sont assis nulle part à ce tour, un ensemble : elle se // porte ceux qui ne sont assis nulle part à ce tour, un ensemble : elle se
// trie par identifiant croissant, quel que soit le drapeau. // trie par identifiant croissant, quel que soit le drapeau.
const PLACEMENT = liste( const PLACEMENT = liste(
objet([ objet([
['sieges', liste(liste(IDENTIFIANT, { triSansAttribution: croissant }))], ['sieges', liste(liste(OCCUPANT, { triSansAttribution: croissant }))],
['reserve', liste(IDENTIFIANT, { tri: croissant })], ['reserve', liste(IDENTIFIANT, { tri: croissant })],
]), ]),
{ mise: 'lignes' }, { mise: 'lignes' },

View file

@ -1026,3 +1026,48 @@ describe('premiereFaute : le parcours de forme, sans lever (§ 8.8, § 8.9)', ()
assert.equal(premiereFauteEnPlace({ ...evenement, nom: '' }, EVENEMENT, 'charge.evenement'), 'charge.evenement.nom'); assert.equal(premiereFauteEnPlace({ ...evenement, nom: '' }, EVENEMENT, 'charge.evenement'), 'charge.evenement.nom');
}); });
}); });
describe('le marqueur de chaise vide : la règle d\'une liste de table (§ 8.9)', () => {
// La proposition du document valide, sièges attribués, dont le tour 2
// laisse vide le siège 2 de la table 3 : sa liste vaut [1, null, 5].
const propositionAChaiseVide = () => {
const proposition = documentValide().charge.propositions[0];
proposition.siegesAttribues = true;
proposition.placement[1].sieges[1] = [1, null, 5];
return proposition;
};
test("premiereFaute admet null dans une liste de table, d'une proposition comme du retenu, et nulle part ailleurs dans un placement", () => {
const regleDe = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
const CHARGE = regleDe(SCHEMA, 'charge');
const PROPOSITION = regleDe(CHARGE, 'propositions').element;
const RETENU = regleDe(CHARGE, 'retenu');
const proposition = propositionAChaiseVide();
const { id, graine, arret, historique, produitVersion, ...plan } = proposition;
const retenu = { proposition: id, ...plan };
assert.equal(premiereFaute(proposition, PROPOSITION), null);
assert.equal(premiereFaute(retenu, RETENU), null);
const cas = [
['dans une réserve', (p) => { p.placement[0].reserve = [null]; }, 'placement[0].reserve[0]'],
['parmi les participants', (p) => { p.participants[1] = null; }, 'participants[1]'],
['parmi les tables', (p) => { p.tables[0] = null; }, 'tables[0]'],
['parmi les capacités', (p) => { p.capacites[1] = null; }, 'capacites[1]'],
['à la place d\'une liste de table', (p) => { p.placement[0].sieges[0] = null; }, 'placement[0].sieges[0]'],
];
assert.ok(cas.length > 0);
for (const [libelle, abimer, chemin] of cas) {
const abimee = propositionAChaiseVide();
abimer(abimee);
assert.equal(premiereFaute(abimee, PROPOSITION), chemin, libelle);
}
});
test("l'analyse lit un fichier dont une proposition porte une chaise vide, et sa relecture rend la liste telle qu'écrite", () => {
const documentLu = documentValide();
documentLu.charge.propositions = [propositionAChaiseVide()];
const { charge } = analyser(JSON.stringify(documentLu));
assert.deepStrictEqual(charge.propositions[0].placement[1].sieges[1], [1, null, 5]);
const relue = analyser(serialiser(charge, { revision: 13, produitVersion: VERSION.affichee })).charge;
assert.deepStrictEqual(relue.propositions[0], propositionAChaiseVide());
});
});

View file

@ -33,9 +33,10 @@
// //
// L'OPFS et le fichier choisi lèvent des DOMException, que le module traduit // L'OPFS et le fichier choisi lèvent des DOMException, que le module traduit
// en codes. Une lecture qui ne trouve pas l'entrée — NotFoundError, ou // en codes. Une lecture qui ne trouve pas l'entrée — NotFoundError, ou
// TypeMismatchError pour une entrée d'un autre genre que celui demandé — // TypeMismatchError pour une entrée d'un autre genre que celui demandé, hors
// lève ABSENT ; toute autre exception d'une lecture lève LECTURE, car une // le cas d'un nom non converti ci-dessous — lève ABSENT ; toute autre
// panne n'est pas une absence ; tout échec d'une écriture lève ECRITURE. Les // exception d'une lecture lève LECTURE, car une panne n'est pas une
// absence ; tout échec d'une écriture lève ECRITURE. Les
// deux portent {chemin, dossier, cause}, dont la cause est le nom de // deux portent {chemin, dossier, cause}, dont la cause est le nom de
// l'exception : SecurityError pour un OPFS que le navigateur refuse à // l'exception : SecurityError pour un OPFS que le navigateur refuse à
// l'origine, NotReadableError pour un fichier qui change pendant qu'on le // l'origine, NotReadableError pour un fichier qui change pendant qu'on le
@ -43,6 +44,17 @@
// TypeMismatchError, QuotaExceededError… Le chemin '' désigne la racine // TypeMismatchError, QuotaExceededError… Le chemin '' désigne la racine
// elle-même, un dossier. // elle-même, un dossier.
// //
// Sous des paramètres régionaux qui ne sont pas UTF-8, Chromium sous Linux
// ne convertit pas un nom hors ASCII et le résout sur le dossier qui le
// porte : un dossier de ce nom rend ce dossier sans lever, un fichier de ce
// nom lève TypeMismatchError comme pour une entrée d'un autre genre. Chaque
// dossier du chemin se compare donc au dossier qui le porte (isSameEntry) ;
// une TypeMismatchError sur un fichier se départage en demandant le même nom
// comme dossier, comparé de même. La même entrée lève, cause
// NOM_NON_CONVERTI — LECTURE pour une lecture, ECRITURE pour une écriture —,
// jamais ABSENT ni EXISTE : rien ne se lit ni ne s'écrit dans le dossier qui
// le porte, et un fichier présent ne se lit pas absent.
//
// Sous web, ni explorateur ni dialogue de dossier : ouvrirDansExplorateur et // Sous web, ni explorateur ni dialogue de dossier : ouvrirDansExplorateur et
// choisirDossier lèvent NON_DISPONIBLE {primitive}. choisirFichierAImporter // choisirDossier lèvent NON_DISPONIBLE {primitive}. choisirFichierAImporter
// passe par un champ de fichier, que le navigateur n'ouvre que dans le // passe par un champ de fichier, que le navigateur n'ouvre que dans le
@ -69,6 +81,10 @@ const TYPE_DIFFERENT = 'TypeMismatchError';
// Cause d'une sonde dont le témoin relu diffère du texte écrit. // Cause d'une sonde dont le témoin relu diffère du texte écrit.
const RELECTURE_DIFFERENTE = 'RELECTURE_DIFFERENTE'; const RELECTURE_DIFFERENTE = 'RELECTURE_DIFFERENTE';
// Cause d'un nom, de dossier ou de fichier, que l'OPFS résout sur le dossier
// qui le porte.
const NOM_NON_CONVERTI = 'NOM_NON_CONVERTI';
// Un fichier s'écrit en UTF-8 sans marque d'ordre d'octets, et se lit sans // Un fichier s'écrit en UTF-8 sans marque d'ordre d'octets, et se lit sans
// retirer celle qu'il porte, que Blob.text retirerait. Le décodage est // retirer celle qu'il porte, que Blob.text retirerait. Le décodage est
// strict : un octet qui n'est pas de l'UTF-8 ne se lit pas U+FFFD. // strict : un octet qui n'est pas de l'UTF-8 ne se lit pas U+FFFD.
@ -79,7 +95,9 @@ const DECODEUR = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
const ignorer = () => {}; const ignorer = () => {};
// Vrai pour l'exception de l'OPFS qui dit qu'une entrée manque : absente, ou // Vrai pour l'exception de l'OPFS qui dit qu'une entrée manque : absente, ou
// d'un autre genre que celui demandé. // d'un autre genre que celui demandé. Une TypeMismatchError de fichier passe
// d'abord par fichierDe, qui lève NOM_NON_CONVERTI à sa place quand le nom
// n'est pas converti.
const estAbsence = (erreur) => erreur?.name === 'NotFoundError' || erreur?.name === TYPE_DIFFERENT; const estAbsence = (erreur) => erreur?.name === 'NotFoundError' || erreur?.name === TYPE_DIFFERENT;
// Cause d'une ECRITURE ou d'une LECTURE : le nom de l'exception, ou son // Cause d'une ECRITURE ou d'une LECTURE : le nom de l'exception, ou son
@ -134,17 +152,50 @@ async function ecrire(loc, travail) {
} }
} }
// Poignée du dossier nom sous parent. Lève une DOMException de nom
// NOM_NON_CONVERTI quand l'OPFS rend parent lui-même pour ce nom.
async function sousDossier(parent, nom, options) {
const dossier = await parent.getDirectoryHandle(nom, options);
if (await dossier.isSameEntry(parent)) throw new DOMException(nom, NOM_NON_CONVERTI);
return dossier;
}
// Vrai quand l'OPFS résout nom, demandé comme dossier, sur dossier lui-même ;
// faux pour toute autre poignée, ou quand la demande lève.
async function resoutSurLuiMeme(dossier, nom) {
try {
return await (await dossier.getDirectoryHandle(nom)).isSameEntry(dossier);
} catch {
return false;
}
}
// Poignée du fichier nom du dossier. Lève l'exception de l'OPFS, sauf une
// TypeMismatchError dont le nom se résout sur le dossier lui-même, qui
// devient une DOMException de nom NOM_NON_CONVERTI.
async function fichierDe(dossier, nom, options) {
try {
return await dossier.getFileHandle(nom, options);
} catch (erreur) {
if (erreur?.name === TYPE_DIFFERENT && (await resoutSurLuiMeme(dossier, nom))) {
throw new DOMException(nom, NOM_NON_CONVERTI);
}
throw erreur;
}
}
// Poignée du dossier que désignent les segments sous le dossier du produit ; // Poignée du dossier que désignent les segments sous le dossier du produit ;
// creer crée ce qui manque sous lui. Le dossier du produit lui-même ne se // creer crée ce qui manque sous lui. Le dossier du produit lui-même ne se
// crée que pour des segments vides, qui le désignent : un chemin sous un // crée que pour des segments vides, qui le désignent : un chemin sous un
// dossier du produit absent lève NotFoundError (§ 8.6). Lève l'exception de // dossier du produit absent lève NotFoundError (§ 8.6). Lève l'exception de
// l'OPFS : NotFoundError pour un dossier absent, TypeMismatchError pour un // l'OPFS : NotFoundError pour un dossier absent, TypeMismatchError pour un
// fichier à sa place. // fichier à sa place ; NOM_NON_CONVERTI pour un dossier résolu sur celui qui
// le porte.
async function dossierDe(segments, creer = false) { async function dossierDe(segments, creer = false) {
const options = { create: creer }; const options = { create: creer };
const produit = { create: creer && segments.length === 0 }; const produit = { create: creer && segments.length === 0 };
let dossier = await (await navigator.storage.getDirectory()).getDirectoryHandle(DOSSIER_PRODUIT, produit); let dossier = await sousDossier(await navigator.storage.getDirectory(), DOSSIER_PRODUIT, produit);
for (const segment of segments) dossier = await dossier.getDirectoryHandle(segment, options); for (const segment of segments) dossier = await sousDossier(dossier, segment, options);
return dossier; return dossier;
} }
@ -159,10 +210,10 @@ async function dossierExistant(segments) {
} }
// Genre de l'entrée nom du dossier : 'fichier', 'dossier', ou null quand elle // Genre de l'entrée nom du dossier : 'fichier', 'dossier', ou null quand elle
// n'existe pas. // n'existe pas. Un nom non converti lève NOM_NON_CONVERTI.
async function genreDe(dossier, nom) { async function genreDe(dossier, nom) {
try { try {
await dossier.getFileHandle(nom); await fichierDe(dossier, nom);
return 'fichier'; return 'fichier';
} catch (erreur) { } catch (erreur) {
if (erreur?.name === TYPE_DIFFERENT) return 'dossier'; if (erreur?.name === TYPE_DIFFERENT) return 'dossier';
@ -200,7 +251,7 @@ async function ecrireParFlux(dossier, nom, garder, remplir) {
let poignee; let poignee;
let cree = false; let cree = false;
try { try {
poignee = await dossier.getFileHandle(nom); poignee = await fichierDe(dossier, nom);
} catch (erreur) { } catch (erreur) {
if (erreur?.name !== 'NotFoundError') throw erreur; if (erreur?.name !== 'NotFoundError') throw erreur;
poignee = await dossier.getFileHandle(nom, { create: true }); poignee = await dossier.getFileHandle(nom, { create: true });
@ -235,7 +286,8 @@ async function decrire(poignee) {
const parNom = (a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : 0); const parNom = (a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : 0);
// La source de deplacer : le dossier qui la porte et ses octets. ABSENT quand // La source de deplacer : le dossier qui la porte et ses octets. ABSENT quand
// elle n'existe pas ; ECRITURE TypeMismatchError quand c'est un dossier. // elle n'existe pas ; ECRITURE TypeMismatchError quand c'est un dossier ;
// NOM_NON_CONVERTI, que genreDe lève, avant toute lecture de ses octets.
async function lireSource(loc) { async function lireSource(loc) {
if (loc.nom === null) throw refus(loc, TYPE_DIFFERENT); if (loc.nom === null) throw refus(loc, TYPE_DIFFERENT);
const dossier = await dossierExistant(loc.parent); const dossier = await dossierExistant(loc.parent);
@ -338,7 +390,7 @@ export function creerFichiersWeb() {
if (loc.nom === null) throw absent(chemin); if (loc.nom === null) throw absent(chemin);
let lu; let lu;
try { try {
lu = await lire(await (await dossierDe(loc.parent)).getFileHandle(loc.nom)); lu = await lire(await fichierDe(await dossierDe(loc.parent), loc.nom));
} catch (erreur) { } catch (erreur) {
throw echecLecture(loc, erreur); throw echecLecture(loc, erreur);
} }

View file

@ -124,6 +124,27 @@ describe('fichiers web : la plateforme et sa racine (§ 8.8, § 13.1)', () => {
expect(await fs.typeSupport(DOCUMENTS)).toBe('inconnu'); expect(await fs.typeSupport(DOCUMENTS)).toBe('inconnu');
}); });
// Sous Linux, Chromium convertit le nom d'une entrée de l'OPFS par les
// paramètres régionaux de son processus. Hors UTF-8 — LANG absent,
// LC_ALL=C —, un nom hors ASCII ne se convertit pas et désigne le dossier
// qui le porte : un fichier y lève TypeMismatchError, un dossier y rend son
// parent. Les épreuves qui écrivent un nom accentué n'éprouvent alors plus
// le module ; celle-ci, sur l'OPFS seul, nomme la cause.
test("l'OPFS de ce Chromium nomme une entrée hors ASCII : un fichier et un dossier accentués se créent et se listent sous leur nom", async () => {
const racine = await opfs();
const cause = 'Chromium tourne sous des paramètres régionaux qui ne sont pas UTF-8 : lancer les épreuves sous LANG=C.UTF-8';
let refus = null;
try {
await racine.getFileHandle('\u{E9}t\u{E9}.txt', { create: true });
} catch (erreur) {
refus = erreur.name;
}
expect(refus, cause).toBeNull();
const dossier = await racine.getDirectoryHandle('\u{E9}', { create: true });
expect(await dossier.isSameEntry(racine), cause).toBe(false);
expect(await nomsOpfs(), cause).toStrictEqual(['\u{E9}', '\u{E9}t\u{E9}.txt']);
});
test("la racine documents est le dossier gestion_table_tournante_libre de l'OPFS : ce qui s'y écrit s'y lit en UTF-8 sans marque d'ordre d'octets, et ce qui s'y pose se lit et se liste", async () => { test("la racine documents est le dossier gestion_table_tournante_libre de l'OPFS : ce qui s'y écrit s'y lit en UTF-8 sans marque d'ordre d'octets, et ce qui s'y pose se lit et se liste", async () => {
const fs = await preparer(); const fs = await preparer();
await fs.creerDossier(DOCUMENTS, 'corbeille'); await fs.creerDossier(DOCUMENTS, 'corbeille');
@ -225,6 +246,92 @@ describe("fichiers web : ce que l'OPFS refuse", () => {
expect(await fs.lireTexte(DOCUMENTS, 'note.txt')).toBe('note'); expect(await fs.lireTexte(DOCUMENTS, 'note.txt')).toBe('note');
}); });
// Sous des paramètres régionaux qui ne sont pas UTF-8, Chromium résout un
// nom de dossier hors ASCII sur le dossier qui le porte, sans lever. La
// doublure rend ce dossier pour le nom « é » : aucun geste ne lit ni n'écrit
// alors dans le dossier qui le porte, où un fichier de même nom attend.
test("un dossier dont la poignée est celle du dossier qui le porte lève NOM_NON_CONVERTI, ECRITURE à l'écriture et LECTURE à la lecture, et le dossier qui le porte reste intact", async () => {
const fs = await preparer();
await fs.ecrireAtomique(DOCUMENTS, 'etat.json', 'parent');
const poignee = FileSystemDirectoryHandle.prototype.getDirectoryHandle;
vi.spyOn(FileSystemDirectoryHandle.prototype, 'getDirectoryHandle').mockImplementation(
async function getDirectoryHandle(nom, options) {
return nom === '\u{E9}' ? this : poignee.call(this, nom, options);
},
);
const sous = `${CHEMIN_DOCUMENTS}/\u{E9}`;
const refus = (chemin, dossier = sous) => ({ chemin, dossier, cause: 'NOM_NON_CONVERTI' });
const enfant = '\u{E9}/etat.json';
await echoue(fs.creerDossier(DOCUMENTS, '\u{E9}'), 'ECRITURE', refus('\u{E9}', CHEMIN_DOCUMENTS), 'creerDossier');
await echoue(fs.creerDossier(DOCUMENTS, '\u{E9}/sous'), 'ECRITURE', refus('\u{E9}/sous'), 'creerDossier dessous');
await echoue(fs.ecrireAtomique(DOCUMENTS, enfant, 'enfant'), 'ECRITURE', refus(enfant), 'ecrireAtomique');
await echoue(fs.ajouterLigne(DOCUMENTS, enfant, 'enfant'), 'ECRITURE', refus(enfant), 'ajouterLigne');
await echoue(fs.supprimer(DOCUMENTS, enfant), 'ECRITURE', refus(enfant), 'supprimer');
await echoue(fs.deplacer(DOCUMENTS, enfant, 'copie.json'), 'ECRITURE', refus(enfant), 'deplacer depuis');
await echoue(fs.deplacer(DOCUMENTS, 'etat.json', '\u{E9}/neuf.json'), 'ECRITURE', refus('\u{E9}/neuf.json'), 'deplacer vers');
await echoue(fs.lireTexte(DOCUMENTS, enfant), 'LECTURE', refus(enfant), 'lireTexte');
await echoue(fs.lister(DOCUMENTS, '\u{E9}'), 'LECTURE', refus('\u{E9}', CHEMIN_DOCUMENTS), 'lister');
vi.restoreAllMocks();
expect(await fs.lister(DOCUMENTS, '')).toMatchObject([{ nom: 'etat.json', type: 'fichier' }]);
expect(await fs.lireTexte(DOCUMENTS, 'etat.json')).toBe('parent');
// Le dossier du produit se contrôle comme tout autre : une poignée qui
// serait la racine de l'OPFS fait échouer la sonde, qui n'y écrit rien.
vi.spyOn(FileSystemDirectoryHandle.prototype, 'getDirectoryHandle').mockImplementation(
async function getDirectoryHandle(nom, options) {
return nom === DOSSIER ? this : poignee.call(this, nom, options);
},
);
expect(await fs.sonder(DOCUMENTS)).toStrictEqual({ inscriptible: false, cause: 'NOM_NON_CONVERTI' });
await echoue(fs.lireTexte(DOCUMENTS, 'etat.json'), 'LECTURE', refus('etat.json', CHEMIN_DOCUMENTS), 'produit');
vi.restoreAllMocks();
expect(await nomsOpfs()).toStrictEqual([DOSSIER]);
});
// Sous les mêmes paramètres régionaux, un nom de fichier hors ASCII se
// résout lui aussi sur le dossier qui le porte : getFileHandle lève
// TypeMismatchError, et getDirectoryHandle rend ce dossier. La doublure
// reproduit les deux pour le nom « ô.txt ». Le refus n'est alors ni une
// absence ni une entrée d'un autre genre : un état présent ne se lit pas
// ABSENT, une cible libre ne se dit pas EXISTE.
test("un fichier que l'OPFS résout sur le dossier qui le porte lève NOM_NON_CONVERTI, LECTURE à la lecture et ECRITURE à l'écriture, jamais ABSENT ni EXISTE", async () => {
const fs = await preparer();
await fs.ecrireAtomique(DOCUMENTS, 'source.txt', 'source');
const nom = '\u{F4}.txt';
const fichier = FileSystemDirectoryHandle.prototype.getFileHandle;
const dossier = FileSystemDirectoryHandle.prototype.getDirectoryHandle;
vi.spyOn(FileSystemDirectoryHandle.prototype, 'getFileHandle').mockImplementation(
async function getFileHandle(entree, options) {
if (entree === nom) throw new DOMException(entree, 'TypeMismatchError');
return fichier.call(this, entree, options);
},
);
vi.spyOn(FileSystemDirectoryHandle.prototype, 'getDirectoryHandle').mockImplementation(
async function getDirectoryHandle(entree, options) {
return entree === nom ? this : dossier.call(this, entree, options);
},
);
const refus = { chemin: nom, dossier: CHEMIN_DOCUMENTS, cause: 'NOM_NON_CONVERTI' };
await echoue(fs.lireTexte(DOCUMENTS, nom), 'LECTURE', refus, 'lireTexte');
await echoue(fs.ecrireAtomique(DOCUMENTS, nom, 'x'), 'ECRITURE', refus, 'ecrireAtomique');
await echoue(fs.ajouterLigne(DOCUMENTS, nom, 'x'), 'ECRITURE', refus, 'ajouterLigne');
await echoue(fs.supprimer(DOCUMENTS, nom), 'ECRITURE', refus, 'supprimer');
await echoue(fs.deplacer(DOCUMENTS, nom, 'copie.txt'), 'ECRITURE', refus, 'deplacer depuis');
await echoue(fs.deplacer(DOCUMENTS, 'source.txt', nom), 'ECRITURE', refus, 'deplacer vers');
// Un vrai dossier reste une entrée d'un autre genre : ABSENT à la lecture.
await fs.creerDossier(DOCUMENTS, 'corbeille');
await echoue(fs.lireTexte(DOCUMENTS, 'corbeille'), 'ABSENT', { chemin: 'corbeille' }, 'vrai dossier');
vi.restoreAllMocks();
expect(await fs.lister(DOCUMENTS, '')).toMatchObject([
{ nom: 'corbeille', type: 'dossier' },
{ nom: 'source.txt', type: 'fichier' },
]);
expect(await fs.lireTexte(DOCUMENTS, 'source.txt')).toBe('source');
});
// Chaque panne frappe un temps de l'écriture par flux : son ouverture, // Chaque panne frappe un temps de l'écriture par flux : son ouverture,
// l'écriture, la fermeture qui porte le contenu sur la cible. // l'écriture, la fermeture qui porte le contenu sur la cible.
const PANNES = [ const PANNES = [

View file

@ -23,14 +23,18 @@
// schéma du fichier d'état, dont l'analyse n'applique que celle du // schéma du fichier d'état, dont l'analyse n'applique que celle du
// conteneur : fautes applique les autres, avant toute cohérence, par le // conteneur : fautes applique les autres, avant toute cohérence, par le
// parcours même de l'analyse, premiereFaute, qui nomme les chemins à sa // parcours même de l'analyse, premiereFaute, qui nomme les chemins à sa
// manière. Aucune fonction ne modifie ce qu'elle reçoit ; aucune ne lit // manière. Une liste de table porte null à la place d'une chaise vide
// l'horloge ni un aléa. // (§ 8.9) : la règle l'admet à toute place, et fautes ne le garde que sous
// des sièges attribués, avant le dernier occupant, siège 1 compris. Aucune
// fonction ne modifie ce qu'elle reçoit ; aucune ne lit l'horloge ni un
// aléa.
import { SCHEMA, capacite, premiereFaute } from './document.js'; import { SCHEMA, capacite, premiereFaute } from './document.js';
import { ErreurStockage } from './erreurs.js'; import { ErreurStockage } from './erreurs.js';
/** /**
* Une faute d'un placement ; types.js en donne la table des codes. FORME : * Une faute d'un placement ; types.js en donne la table des codes. FORME :
* une valeur sort de sa règle, ou la déclaration se contredit ; chemin * une valeur sort de sa règle, la déclaration se contredit, ou une chaise
* vide est mal placée — sans attribution des sièges, ou en fin de liste ; chemin
* désigne l'élément fautif dans le placement, à la manière de l'analyse du * désigne l'élément fautif dans le placement, à la manière de l'analyse du
* fichier, '' pour le placement lui-même. LONGUEUR : une longueur écrite * fichier, '' pour le placement lui-même. LONGUEUR : une longueur écrite
* contredit celle que le placement déclare ; tour et table null désignent le * contredit celle que le placement déclare ; tour et table null désignent le
@ -66,8 +70,9 @@ import { ErreurStockage } from './erreurs.js';
* @property {number|null} numero numéro affiché de la table dans la * @property {number|null} numero numéro affiché de la table dans la
* charge ; null pour la réserve et pour * charge ; null pour la réserve et pour
* une table que la charge n'a plus * une table que la charge n'a plus
* @property {number|null} siege rang dans la liste, à partir de 1, quand * @property {number|null} siege rang dans la liste, à partir de 1,
* les sièges sont attribués ; null sinon * chaises vides comprises, quand les
* sièges sont attribués ; null sinon
* @property {number} participant * @property {number} participant
* @property {string|null} nom null pour une personne que la charge * @property {string|null} nom null pour une personne que la charge
* n'a plus * n'a plus
@ -93,8 +98,9 @@ import { ErreurStockage } from './erreurs.js';
const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1]; const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
const CHARGE = regleDuChamp(SCHEMA, 'charge'); const CHARGE = regleDuChamp(SCHEMA, 'charge');
// Règles d'une proposition et du retenu. Aucune des valeurs qu'elles // Règles d'une proposition et du retenu. Aucune des valeurs qu'elles
// contiennent n'admet null ; examiner traite le retenu nul, son absence, // contiennent n'admet null, hors de l'occupant d'une liste de table, la
// avant de lire le retenu. // chaise vide ; examiner traite le retenu nul, son absence, avant de lire
// le retenu.
const PROPOSITION = regleDuChamp(CHARGE, 'propositions').element; const PROPOSITION = regleDuChamp(CHARGE, 'propositions').element;
const RETENU = regleDuChamp(CHARGE, 'retenu'); const RETENU = regleDuChamp(CHARGE, 'retenu');
const IDENTIFIANT = regleDuChamp(PROPOSITION, 'id'); const IDENTIFIANT = regleDuChamp(PROPOSITION, 'id');
@ -127,14 +133,32 @@ function fauteDeDeclaration({ tables, capacites, participants }) {
); );
} }
// Chemin de la première chaise vide mal placée d'un placement de forme et
// de déclaration conformes, ou null, dans l'ordre des tours, des listes et
// des rangs : un null sous siegesAttribues faux, où l'ordre d'une liste ne
// dit aucun siège, ou au dernier rang d'une liste, qu'une écriture sans
// lui dit pareil. Le chemin suit la convention de premiereFaute.
function fauteDeChaiseVide({ siegesAttribues, placement }) {
for (let tour = 0; tour < placement.length; tour += 1) {
const { sieges } = placement[tour];
for (let table = 0; table < sieges.length; table += 1) {
const liste = sieges[table];
const rang = siegesAttribues ? liste.length - 1 : liste.indexOf(null);
if (rang >= 0 && liste[rang] === null) return `placement[${tour}].sieges[${table}][${rang}]`;
}
}
return null;
}
// Fautes de cohérence d'un placement de forme et de déclaration conformes // Fautes de cohérence d'un placement de forme et de déclaration conformes
// (§ 8.9, points 1 et 2). D'abord le nombre de tours écrits contre celui // (§ 8.9, points 1 et 2). D'abord le nombre de tours écrits contre celui
// déclaré. Puis, tour après tour : le nombre de listes de table contre celui // déclaré. Puis, tour après tour : le nombre de listes de table contre celui
// des tables déclarées ; la longueur de chaque liste appariée à une table // des tables déclarées ; la longueur de chaque liste appariée à une table
// déclarée, dans l'ordre déclaré, contre sa capacité ; le multiensemble du // déclarée, dans l'ordre déclaré, contre sa capacité, chaises vides
// tour, listes de table et réserve comprises, où chaque identifiant déclaré // comprises ; le multiensemble du tour, listes de table et réserve
// figure exactement une fois — doublons, inconnus, puis manquants, chacun // comprises, où chaque identifiant déclaré figure exactement une fois —
// nommé une fois par tour, par identifiant croissant. // doublons, inconnus, puis manquants, chacun nommé une fois par tour, par
// identifiant croissant. Une chaise vide n'y entre pas.
// //
// La capacité borne une liste sans la fixer : quand la salle offre plus de // La capacité borne une liste sans la fixer : quand la salle offre plus de
// sièges que de personnes, des tables gardent des places vides, et une liste // sièges que de personnes, des tables gardent des places vides, et une liste
@ -167,6 +191,7 @@ function fautesDeCoherence({ tables, capacites, tours, participants, placement }
const inconnus = []; const inconnus = [];
for (const ids of [...sieges, reserve]) { for (const ids of [...sieges, reserve]) {
for (const id of ids) { for (const id of ids) {
if (id === null) continue;
const deja = occurrences.get(id) ?? 0; const deja = occurrences.get(id) ?? 0;
occurrences.set(id, deja + 1); occurrences.set(id, deja + 1);
if (deja === 0 && !estDeclare.has(id)) inconnus.push(id); if (deja === 0 && !estDeclare.has(id)) inconnus.push(id);
@ -183,11 +208,11 @@ function fautesDeCoherence({ tables, capacites, tours, participants, placement }
return liste; return liste;
} }
// Fautes de valeur sous regle : la première faute de forme ou de // Fautes de valeur sous regle : la première faute de forme, de
// déclaration seule, qui rend la cohérence illisible ; sinon les fautes de // déclaration ou de chaise vide seule, qui rend la cohérence illisible ;
// cohérence. // sinon les fautes de cohérence.
function fautesSelon(valeur, regle) { function fautesSelon(valeur, regle) {
const chemin = premiereFaute(valeur, regle) ?? fauteDeDeclaration(valeur); const chemin = premiereFaute(valeur, regle) ?? fauteDeDeclaration(valeur) ?? fauteDeChaiseVide(valeur);
return chemin === null ? fautesDeCoherence(valeur) : [{ code: 'FORME', chemin }]; return chemin === null ? fautesDeCoherence(valeur) : [{ code: 'FORME', chemin }];
} }
@ -197,7 +222,9 @@ function fautesSelon(valeur, regle) {
* retenu quand p porte la clé proposition, celle d'une proposition sinon. * retenu quand p porte la clé proposition, celle d'une proposition sinon.
* *
* La forme se contrôle d'abord, dans l'ordre du schéma, puis la * La forme se contrôle d'abord, dans l'ordre du schéma, puis la
* déclaration : la première faute de l'une ou de l'autre est seule rendue, * déclaration, puis les chaises vides : null, admis par la règle d'une
* liste de table, n'y est cohérent que sous des sièges attribués et avant
* le dernier rang. La première de ces fautes est seule rendue,
* { code: 'FORME', chemin }. Sinon, dans l'ordre des tours puis des tables : * { code: 'FORME', chemin }. Sinon, dans l'ordre des tours puis des tables :
* { code: 'LONGUEUR', tour, table, declare, ecrit } — tour null pour le * { code: 'LONGUEUR', tour, table, declare, ecrit } — tour null pour le
* nombre de tours, rendu en premier ; table null pour le nombre de listes * nombre de tours, rendu en premier ; table null pour le nombre de listes
@ -248,8 +275,9 @@ function exigerCoherent(p, fonction) {
* configuration, lue par identifiant ; le nombre de tours ; les participants * configuration, lue par identifiant ; le nombre de tours ; les participants
* non exclus, par identifiant croissant. Le placement reprend le plan tour * non exclus, par identifiant croissant. Le placement reprend le plan tour
* par tour : sans attribution de sièges, chaque liste de table se trie par * par tour : sans attribution de sièges, chaque liste de table se trie par
* identifiant croissant ; avec, elle garde son ordre, celui des sièges. La * identifiant croissant ; avec, elle garde son ordre, celui des sièges, et
* réserve, un ensemble, se trie dans les deux cas. Les clés suivent l'ordre * ses chaises vides, null. La réserve, un ensemble, se trie dans les deux
* cas. Les clés suivent l'ordre
* du schéma ; rien n'est partagé avec les arguments, qui ne sont pas * du schéma ; rien n'est partagé avec les arguments, qui ne sont pas
* modifiés. * modifiés.
* *
@ -263,7 +291,8 @@ function exigerCoherent(p, fonction) {
* entier ≥ 0, ou quand la forme produite serait fautive : attribuerSieges * entier ≥ 0, ou quand la forme produite serait fautive : attribuerSieges
* qui n'est pas un booléen, version vide ou absente, réglage hors de son * qui n'est pas un booléen, version vide ou absente, réglage hors de son
* domaine, plan que la configuration contredit — table inconnue d'elle, * domaine, plan que la configuration contredit — table inconnue d'elle,
* nombre de tours, personne non placée, liste au-delà d'une capacité. * nombre de tours, personne non placée, liste au-delà d'une capacité,
* chaise vide sans attribution ou en fin de liste.
* *
* @param {import('../moteur/recherche.js').Proposition} proposition * @param {import('../moteur/recherche.js').Proposition} proposition
* @param {import('../moteur/types.js').Configuration} configuration * @param {import('../moteur/types.js').Configuration} configuration
@ -309,9 +338,9 @@ export function versFichier(proposition, configuration, { produitVersion, attrib
/** /**
* Plan du moteur d'un placement de fichier, proposition ou retenu : les * Plan du moteur d'un placement de fichier, proposition ou retenu : les
* tables dans l'ordre déclaré, chaque liste de table et chaque réserve par * tables dans l'ordre déclaré, chaque liste de table sans ses chaises
* identifiant croissant, comme le moteur les rend ; l'ordre des sièges n'y * vides, et chaque réserve, par identifiant croissant, comme le moteur les
* entre pas. Aucune liste n'est partagée avec p. Lève TypeError quand p * rend ; l'ordre des sièges n'y entre pas. Aucune liste n'est partagée avec p. Lève TypeError quand p
* n'est pas cohérent (fautes). * n'est pas cohérent (fautes).
* *
* @param {import('./types.js').PropositionFichier|import('./types.js').Retenu} p * @param {import('./types.js').PropositionFichier|import('./types.js').Retenu} p
@ -321,7 +350,7 @@ export function planDepuisFichier(p) {
exigerCoherent(p, 'planDepuisFichier'); exigerCoherent(p, 'planDepuisFichier');
return { return {
tables: [...p.tables], tables: [...p.tables],
tours: p.placement.map(({ sieges }) => sieges.map((liste) => [...liste].sort(croissant))), tours: p.placement.map(({ sieges }) => sieges.map((liste) => liste.filter((id) => id !== null).sort(croissant))),
reserves: p.placement.map(({ reserve }) => [...reserve].sort(croissant)), reserves: p.placement.map(({ reserve }) => [...reserve].sort(croissant)),
}; };
} }
@ -411,7 +440,8 @@ const ordreDesTables = (a, b) =>
* l'identifiant départage deux numéros égaux —, puis par rang dans la * l'identifiant départage deux numéros égaux —, puis par rang dans la
* liste ; la réserve suit les tables de son tour, table et numéro null. * liste ; la réserve suit les tables de son tour, table et numéro null.
* siege est le rang à partir de 1 quand le placement attribue les sièges, * siege est le rang à partir de 1 quand le placement attribue les sièges,
* null sinon et dans la réserve. Nom et prénom viennent de la charge ; une * null sinon et dans la réserve ; une chaise vide, null dans la liste,
* garde son rang et n'a pas de ligne. Nom et prénom viennent de la charge ; une
* personne qu'elle n'a plus, ou qu'elle n'a jamais eue, les a null. * personne qu'elle n'a plus, ou qu'elle n'a jamais eue, les a null.
* *
* Un placement incohérent (fautes : LONGUEUR, DOUBLON, INCONNU, MANQUANT) * Un placement incohérent (fautes : LONGUEUR, DOUBLON, INCONNU, MANQUANT)
@ -454,6 +484,7 @@ export function formeNommee(p, charge) {
// au-delà des tables déclarées. // au-delà des tables déclarées.
const ajouterListe = (tour, ids, table, numero) => { const ajouterListe = (tour, ids, table, numero) => {
ids.forEach((participant, position) => { ids.forEach((participant, position) => {
if (participant === null) return;
ajouter(tour, table, numero, p.siegesAttribues ? position + 1 : null, participant, false); ajouter(tour, table, numero, p.siegesAttribues ? position + 1 : null, participant, false);
}); });
}; };

View file

@ -1495,3 +1495,140 @@ describe('déterminisme (§ 8.8, § 14.7)', () => {
assert.deepStrictEqual(fautes(alphaPuisZeta), [{ code: 'FORME', chemin: 'alpha' }]); assert.deepStrictEqual(fautes(alphaPuisZeta), [{ code: 'FORME', chemin: 'alpha' }]);
}); });
}); });
// fichierParfait, sièges attribués, dont le tour 1 laisse vide le siège 2
// de la table 1 : sa liste vaut [12, null, 7], et la personne 2 attend en
// réserve. Une copie neuve à chaque appel.
function fichierAChaiseVide() {
const p = fichierParfait();
p.siegesAttribues = true;
p.placement[0] = { sieges: [[12, null, 7], [4, 5, 6], [1, 8, 9], [10, 11, 3]], reserve: [2] };
return p;
}
describe('le marqueur de chaise vide : null avant le dernier occupant, sièges attribués (§ 8.9)', () => {
test("une liste [12, null, 7] est cohérente, proposition comme retenu : le null compte dans la longueur, n'est ni DOUBLON ni INCONNU, et planDepuisFichier le retire", () => {
const p = fichierAChaiseVide();
assert.deepStrictEqual(fautes(p), []);
assert.deepStrictEqual(fautes(retenuDe(p)), []);
assert.deepStrictEqual(planDepuisFichier(p).tours[0], [[7, 12], [4, 5, 6], [1, 8, 9], [3, 10, 11]]);
assert.deepStrictEqual(planDepuisFichier(retenuDe(p)).tours[0][0], [7, 12]);
// Deux chaises vides au même tour, à deux tables : aucune n'est un
// doublon de l'autre.
const deux = fichierAChaiseVide();
deux.placement[0] = { sieges: [[12, null, 7], [4, null, 6], [1, 8, 9], [10, 11, 3]], reserve: [2, 5] };
assert.deepStrictEqual(fautes(deux), []);
assert.deepStrictEqual(planDepuisFichier(deux).tours[0].slice(0, 2), [[7, 12], [4, 6]]);
// Le null occupe un siège : quatre éléments sur une capacité de 3.
const longue = fichierAChaiseVide();
longue.placement[0] = { sieges: [[12, null, 7, 9], [4, 5, 6], [1, 8], [10, 11, 3]], reserve: [2] };
assert.deepStrictEqual(fautes(longue), [{ code: 'LONGUEUR', tour: 1, table: 1, declare: 3, ecrit: 4 }]);
});
test("une chaise vide au siège 1, [null, 12, 7] ou [null, null, 7], est cohérente, proposition comme retenu : planDepuisFichier la retire, et formeNommee garde le siège de chaque occupant", () => {
const cas = [
[[null, 12, 7], [2], [7, 12], [ligne(1, 1, 1, 2, 12, 'Mélisse', 'Mathurin'), ligne(1, 1, 1, 3, 7, 'Giroflée', 'Gisèle')]],
[[null, null, 7], [2, 12], [7], [ligne(1, 1, 1, 3, 7, 'Giroflée', 'Gisèle')]],
];
assert.ok(cas.length > 0);
for (const [liste, reserve, occupants, assis] of cas) {
const p = fichierAChaiseVide();
p.placement[0] = { sieges: [liste, [4, 5, 6], [1, 8, 9], [10, 11, 3]], reserve };
assert.deepStrictEqual(fautes(p), [], JSON.stringify(liste));
assert.deepStrictEqual(fautes(retenuDe(p)), [], JSON.stringify(liste));
assert.deepStrictEqual(planDepuisFichier(p).tours[0][0], occupants);
assert.deepStrictEqual(planDepuisFichier(retenuDe(p)).tours[0][0], occupants);
const lignes = formeNommee(p, chargePetite());
assert.deepStrictEqual(lignes.filter(({ tour, table: id }) => tour === 1 && id === 1), assis);
assert.deepStrictEqual(
lignes.filter(({ tour, reserve: enReserve }) => tour === 1 && enReserve).map(({ participant }) => participant),
reserve,
);
}
});
test("sans attribution, ou en fin de liste, null est une faute FORME qui nomme la chaise ; seul sous sa règle, il ne s'admet nulle part ailleurs dans un placement", () => {
const cas = [
['sièges non attribués', (p) => { p.siegesAttribues = false; }, 'placement[0].sieges[0][1]'],
['une liste qui finit par null', (p) => {
p.placement[0] = { sieges: [[12, null], [4, 5, 6], [1, 8, 9], [10, 11, 3]], reserve: [2, 7] };
}, 'placement[0].sieges[0][1]'],
['une liste réduite à null', (p) => {
p.placement[1].sieges[3] = [null];
p.placement[1].reserve = [1, 4, 7];
}, 'placement[1].sieges[3][0]'],
['une chaise vide admise au tour 1, puis une fin nulle au tour 3', (p) => {
p.placement[2].sieges[0] = [4, 8, null];
p.placement[2].reserve = [10];
}, 'placement[2].sieges[0][2]'],
['une fin nulle au dernier tour, à la dernière table', (p) => {
p.placement[3].sieges[3] = [2, 6, null];
p.placement[3].reserve = [8];
}, 'placement[3].sieges[3][2]'],
['sièges non attribués, une chaise vide au siège 1', (p) => {
p.siegesAttribues = false;
p.placement[0] = { sieges: [[null, 7, 12], [4, 5, 6], [1, 8, 9], [3, 10, 11]], reserve: [2] };
}, 'placement[0].sieges[0][0]'],
['null dans la réserve', (p) => { p.placement[0].reserve = [2, null]; }, 'placement[0].reserve[1]'],
['null parmi les participants', (p) => { p.participants[11] = null; }, 'participants[11]'],
['null parmi les tables', (p) => { p.tables[3] = null; }, 'tables[3]'],
];
assert.ok(cas.length > 0);
const ecarts = cas.flatMap(([libelle, abimer, chemin]) =>
[fichierAChaiseVide(), retenuDe(fichierAChaiseVide())].flatMap((p) => {
abimer(p);
const rendu = fautes(p);
const attendu = [{ code: 'FORME', chemin }];
return JSON.stringify(rendu) === JSON.stringify(attendu) ? [] : [`${libelle} : ${JSON.stringify(rendu)}`];
}),
);
assert.deepEqual(ecarts, []);
leveTypeError(() => planDepuisFichier({ ...fichierAChaiseVide(), siegesAttribues: false }), /FORME/);
});
test('formeNommee assied 12 au siège 1 et 7 au siège 3, sans ligne pour la chaise vide', () => {
const lignes = formeNommee(fichierAChaiseVide(), chargePetite());
assert.equal(lignes.length, 48);
assert.ok(lignes.every(({ participant }) => participant !== null));
assert.deepStrictEqual(lignes.filter(({ tour, table: id }) => tour === 1 && id === 1), [
ligne(1, 1, 1, 1, 12, 'Mélisse', 'Mathurin'),
ligne(1, 1, 1, 3, 7, 'Giroflée', 'Gisèle'),
]);
assert.deepStrictEqual(
lignes.filter(({ tour, reserve }) => tour === 1 && reserve),
[ligne(1, null, null, null, 2, 'Bruyère', 'Basile')],
);
});
test('versFichier garde les chaises vides du plan reçu quand les sièges sont attribués, et lève sans attribution', () => {
const plan = {
tables: [1, 2, 3, 4],
tours: [[[12, null, 7], [4, 5, 6], [1, 8, 9], [10, 11, 3]], ...PLAN_PARFAIT_PETITE.tours.slice(1)],
reserves: [[2], [], [], []],
};
const proposition = { ...PARFAITE, plan };
const options = { produitVersion: VERSION.affichee, decalage: 0 };
const fichier = versFichier(proposition, petite(), { ...options, attribuerSieges: true });
assert.deepStrictEqual(fichier.placement[0].sieges, [[12, null, 7], [4, 5, 6], [1, 8, 9], [10, 11, 3]]);
assert.deepStrictEqual(fichier, fichierAChaiseVide());
leveTypeError(() => versFichier(proposition, petite(), { ...options, attribuerSieges: false }), /FORME/);
});
test("le fichier relu porte la liste telle quelle : examiner garde la proposition et le retenu sans faute, et écarte la proposition sans attribution qui porte null", () => {
const charge = chargePetite();
const proposition = fichierAChaiseVide();
charge.propositions = [proposition, { ...fichierAChaiseVide(), id: 2, siegesAttribues: false }];
charge.retenu = retenuDe(proposition);
charge.prochainsIds.proposition = 3;
charge.evenement.etat = 'retenu';
const relue = relire(charge);
assert.deepStrictEqual(relue.retenu.placement[0].sieges[0], [12, null, 7]);
assert.deepStrictEqual(relue.propositions[0].placement[0].sieges[0], [12, null, 7]);
const examen = examiner(relue);
assert.deepStrictEqual(examen.retenu, { fautes: [], raisons: [] });
assert.deepStrictEqual(examen.charge.propositions, [fichierAChaiseVide()]);
assert.deepStrictEqual(examen.fautives, [
{ rang: 1, id: 2, fautes: [{ code: 'FORME', chemin: 'placement[0].sieges[0][0]' }] },
]);
});
});

View file

@ -1,9 +1,9 @@
// © 2026 TechnoLibre (http://www.technolibre.ca) // © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Gardes de l'arborescence, lues dans le texte des sources : le moteur, le // Gardes de l'arborescence, lues dans le texte des sources : le moteur, la
// générateur de démonstrations, le stockage et l'analyseur CSV n'appellent // géométrie du plan, le générateur de démonstrations, le stockage et
// aucune source non reproductible (§ 14.7), et ne parcourent les clés d'un // l'analyseur CSV 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 de // objet que par exception nommée (§ 15.5, point 4) ; le graphe d'imports de
// chaque couche ne rejoint que ce que le § 13.4 lui permet ; chaque paquet // chaque couche ne rejoint que ce que le § 13.4 lui permet ; chaque paquet
// importé est déclaré dans devDependencies ; une valeur du contrat de données // importé est déclaré dans devDependencies ; une valeur du contrat de données
@ -138,10 +138,10 @@ const PARCOURS_DE_CLES_ADMIS = [
]; ];
// Arbres dont les modules ne lisent aucune source non reproductible (§ 14.7) : // Arbres dont les modules ne lisent aucune source non reproductible (§ 14.7) :
// le moteur et le générateur de démonstrations, puis le stockage et // le moteur, la géométrie du plan et le générateur de démonstrations, puis le
// l'analyseur CSV, qui reçoivent l'horloge et l'aléa de l'application, en // stockage et l'analyseur CSV, qui reçoivent l'horloge et l'aléa de
// paramètre. // l'application, en paramètre.
const ARBRES_DETERMINISTES = ['src/moteur', 'src/demo', 'src/stockage', 'src/csv']; const ARBRES_DETERMINISTES = ['src/moteur', 'src/geometrie', 'src/demo', 'src/stockage', 'src/csv'];
// Vrai quand le motif se lit dans le texte à partir de l'indice donné, et non // Vrai quand le motif se lit dans le texte à partir de l'indice donné, et non
// plus loin : une copie collante du motif, ses drapeaux g et y retirés. // plus loin : une copie collante du motif, ses drapeaux g et y retirés.
@ -196,6 +196,7 @@ function releverDeterminisme(racine, admis = PARCOURS_DE_CLES_ADMIS) {
// pour éprouver le refus d'un balayage vide. // pour éprouver le refus d'un balayage vide.
const MODULES_VIDES = Object.freeze({ const MODULES_VIDES = Object.freeze({
'src/moteur/a.js': '', 'src/moteur/a.js': '',
'src/geometrie/a.js': '',
'src/demo/a.js': '', 'src/demo/a.js': '',
'src/stockage/a.js': '', 'src/stockage/a.js': '',
'src/csv/a.js': '', 'src/csv/a.js': '',
@ -210,7 +211,7 @@ const sansArbre = (fichiers, arbre) =>
const releverSansException = (racine) => releverDeterminisme(racine, []); const releverSansException = (racine) => releverDeterminisme(racine, []);
describe('arborescence : déterminisme (§ 14.7)', () => { describe('arborescence : déterminisme (§ 14.7)', () => {
test("ni src/moteur, ni src/demo, ni src/stockage, ni src/csv ne lisent une source non reproductible, ni ne parcourent les clés d'un objet hors des exceptions nommées", () => { test("ni src/moteur, ni src/geometrie, ni src/demo, ni src/stockage, ni src/csv ne lisent une source non reproductible, ni ne parcourent les clés d'un objet hors des exceptions nommées", () => {
assert.deepEqual(releverDeterminisme(RACINE), []); assert.deepEqual(releverDeterminisme(RACINE), []);
}); });
@ -315,7 +316,7 @@ describe('arborescence : déterminisme (§ 14.7)', () => {
'src/demo/tirage.cjs:1 Date.now', 'src/demo/tirage.cjs:1 Date.now',
'src/demo/tirage.mjs:1 Math.random', 'src/demo/tirage.mjs:1 Math.random',
]); ]);
for (const arbre of ['src/moteur', 'src/demo', 'src/stockage', 'src/csv']) { for (const arbre of ['src/moteur', 'src/geometrie', 'src/demo', 'src/stockage', 'src/csv']) {
const sansModule = { ...sansArbre(MODULES_VIDES, arbre), [`${arbre}/a.test.js`]: '' }; const sansModule = { ...sansArbre(MODULES_VIDES, arbre), [`${arbre}/a.test.js`]: '' };
assert.throws( assert.throws(
() => avecArbre(sansModule, releverSansException), () => avecArbre(sansModule, releverSansException),
@ -325,6 +326,24 @@ describe('arborescence : déterminisme (§ 14.7)', () => {
} }
}); });
test('src/geometrie se relève comme le moteur, à toute profondeur, hors épreuves', () => {
const fichiers = {
...MODULES_VIDES,
'src/geometrie/vue.js': [
'export const a = Math.random();',
'export const b = (o) => Object.keys(o).map((cle) => o[cle]);',
'export const c = Math.min(1, 2) + Math.floor(2.5);',
].join('\n'),
'src/geometrie/sous/texte.js': 'for (const champ in ligne) t.push(champ);\n',
'src/geometrie/vue.test.js': 'export const d = Math.random() + Object.keys(o).length;\n',
};
assert.deepEqual(avecArbre(fichiers, releverSansException), [
'src/geometrie/sous/texte.js:1 for…in',
'src/geometrie/vue.js:1 Math.random',
'src/geometrie/vue.js:2 Object.keys',
]);
});
test('src/stockage et src/csv se relèvent comme le moteur et le générateur, à toute profondeur, hors épreuves', () => { test('src/stockage et src/csv se relèvent comme le moteur et le générateur, à toute profondeur, hors épreuves', () => {
const fichiers = { const fichiers = {
...MODULES_VIDES, ...MODULES_VIDES,
@ -470,7 +489,9 @@ const PAQUETS_DE_PLATEFORME = /^(?:svelte|electron|@capacitor\/.+)$/;
// l'application. // l'application.
// //
// Le moteur et la géométrie ne connaissent ni l'écran, ni les fichiers, ni ce // Le moteur et la géométrie ne connaissent ni l'écran, ni les fichiers, ni ce
// qui les commande. Le stockage lit et écrit par le système de fichiers qu'il // qui les commande. La géométrie est une couche à part, plus bas que le
// moteur : des fonctions pures du plan, qui n'importent ni le moteur, ni
// l'analyseur CSV, ni le générateur de démonstrations. Le stockage lit et écrit par le système de fichiers qu'il
// reçoit ; seules ses deux implémentations de plateforme touchent le // reçoit ; seules ses deux implémentations de plateforme touchent le
// navigateur, par l'OPFS sous web et par le pont window.gtt sous Electron. // navigateur, par l'OPFS sous web et par le pont window.gtt sous Electron.
// L'analyseur CSV lit le modèle du stockage. L'application câble le moteur, // L'analyseur CSV lit le modèle du stockage. L'application câble le moteur,
@ -478,12 +499,19 @@ const PAQUETS_DE_PLATEFORME = /^(?:svelte|electron|@capacitor\/.+)$/;
// l'interface. // l'interface.
const COUCHES = [ const COUCHES = [
{ {
arbres: ['src/moteur', 'src/geometrie'], arbres: ['src/moteur'],
requis: ['src/moteur'], requis: ['src/moteur'],
exterieures: ['interface', 'application', 'stockage'], exterieures: ['interface', 'application', 'stockage'],
paquets: PAQUETS_DE_PLATEFORME, paquets: PAQUETS_DE_PLATEFORME,
navigateur: [], navigateur: [],
}, },
{
arbres: ['src/geometrie'],
requis: ['src/geometrie'],
exterieures: ['interface', 'application', 'stockage', 'moteur', 'csv', 'demo'],
paquets: PAQUETS_DE_PLATEFORME,
navigateur: [],
},
{ {
arbres: ['src/stockage'], arbres: ['src/stockage'],
requis: ['src/stockage'], requis: ['src/stockage'],
@ -815,10 +843,10 @@ describe('arborescence : frontière des couches (§ 13.4)', () => {
'src/moteur/a.js:26 import ../application/seance.js', 'src/moteur/a.js:26 import ../application/seance.js',
'src/moteur/b.js:4 import ../interface/App.svelte', 'src/moteur/b.js:4 import ../interface/App.svelte',
'src/moteur/sous/c.js:1 import ../../interface/x.js', 'src/moteur/sous/c.js:1 import ../../interface/x.js',
'src/moteur/a.js → src/commun/aide.js:2 window.',
'src/geometrie/echelle.js:1 window.', 'src/geometrie/echelle.js:1 window.',
'src/geometrie/echelle.js:3 import ../application/commandes.js', 'src/geometrie/echelle.js:3 import ../application/commandes.js',
'src/geometrie/echelle.js:4 import ../application/seance.js', 'src/geometrie/echelle.js:4 import ../application/seance.js',
'src/moteur/a.js → src/commun/aide.js:2 window.',
'src/geometrie/echelle.js → src/commun/racine.js:1 window.', 'src/geometrie/echelle.js → src/commun/racine.js:1 window.',
'src/stockage/fichiers.js:1 import @capacitor/core', 'src/stockage/fichiers.js:1 import @capacitor/core',
]); ]);
@ -989,10 +1017,57 @@ describe('arborescence : frontière des couches (§ 13.4)', () => {
]); ]);
}); });
test('src/geometrie et src/application peuvent manquer ; un src/moteur, un src/stockage ou un src/csv sans module fait échouer la garde', () => { test("src/geometrie n'importe ni le moteur, ni le CSV, ni les démonstrations : une couche à part, plus bas que le moteur", () => {
const fichiers = {
...MODULES_VIDES,
'src/geometrie/vue.js': [
"import { mesurer } from '../moteur/indicateurs.js';",
"import { decouper } from '../csv/lecture.js';",
"import { creerPcg32 } from '../demo/prng.js';",
"import { tronquer } from './texte.js';",
].join('\n'),
'src/geometrie/texte.js': 'export const tronquer = (t) => t;\n',
'src/moteur/indicateurs.js': 'export const mesurer = () => 0;\n',
'src/csv/lecture.js': 'export const decouper = () => [];\n',
'src/demo/prng.js': 'export const creerPcg32 = () => null;\n',
};
assert.deepEqual(avecArbre(fichiers, releverFrontiere), [
'src/geometrie/vue.js:1 import ../moteur/indicateurs.js',
'src/geometrie/vue.js:2 import ../csv/lecture.js',
'src/geometrie/vue.js:3 import ../demo/prng.js',
]);
});
test("src/geometrie n'importe ni l'interface, ni l'application, ni le stockage, ni un paquet de plateforme", () => {
const fichiers = {
...MODULES_VIDES,
'src/geometrie/vue.js': [
"import { x } from '../interface/x.js';",
"import { y } from '../application/y.js';",
"import { lire } from '../stockage/document.js';",
"import { mount } from 'svelte';",
"import { writable } from 'svelte/store';",
"import { app } from 'electron';",
"import { Capacitor } from '@capacitor/core';",
"import { tronquer } from './texte.js';",
].join('\n'),
'src/geometrie/texte.js': 'export const tronquer = (t) => t;\n',
};
assert.deepEqual(avecArbre(fichiers, releverFrontiere), [
'src/geometrie/vue.js:1 import ../interface/x.js',
'src/geometrie/vue.js:2 import ../application/y.js',
'src/geometrie/vue.js:3 import ../stockage/document.js',
'src/geometrie/vue.js:4 import svelte',
'src/geometrie/vue.js:5 import svelte/store',
'src/geometrie/vue.js:6 import electron',
'src/geometrie/vue.js:7 import @capacitor/core',
]);
});
test('src/application peut manquer ; un src/moteur, un src/geometrie, un src/stockage ou un src/csv sans module fait échouer la garde', () => {
assert.deepEqual(avecArbre(MODULES_VIDES, releverFrontiere), []); assert.deepEqual(avecArbre(MODULES_VIDES, releverFrontiere), []);
for (const arbre of ['src/moteur', 'src/stockage', 'src/csv']) { for (const arbre of ['src/moteur', 'src/geometrie', 'src/stockage', 'src/csv']) {
const sansModule = { ...sansArbre(MODULES_VIDES, arbre), [`${arbre}/a.test.js`]: '', 'src/geometrie/echelle.js': '' }; const sansModule = { ...sansArbre(MODULES_VIDES, arbre), [`${arbre}/a.test.js`]: '' };
assert.throws( assert.throws(
() => avecArbre(sansModule, releverFrontiere), () => avecArbre(sansModule, releverFrontiere),
new RegExp(`${arbre} ne fournit aucun module`), new RegExp(`${arbre} ne fournit aucun module`),

View file

@ -6,7 +6,41 @@
// téléchargé ; GTT_CHROMIUM désigne un autre exécutable. Sous NixOS, où les // téléchargé ; GTT_CHROMIUM désigne un autre exécutable. Sous NixOS, où les
// binaires téléchargés ne démarrent pas, scripts/installation/shell.nix y // binaires téléchargés ne démarrent pas, scripts/installation/shell.nix y
// met celui de nixpkgs. // met celui de nixpkgs.
export function optionsChromium(env) { //
const chemin = env.GTT_CHROMIUM; // Sous Linux, Chromium convertit le nom d'une entrée de l'OPFS par les
return chemin ? { launchOptions: { executablePath: chemin } } : {}; // paramètres régionaux de son processus : hors UTF-8, un nom hors ASCII ne
// se convertit pas, et les épreuves du navigateur n'éprouvent plus le
// stockage. Les paramètres régionaux qui décident de cette conversion sont
// ceux de LC_CTYPE : LC_ALL, puis LC_CTYPE, puis LANG, la première variable
// non vide ; aucune, c'est « C ». Quand ils ne sont pas UTF-8, Chromium
// reçoit une copie de l'environnement sous LANG=C.UTF-8, LC_ALL et LC_CTYPE
// retirés puisqu'ils l'emporteraient sur LANG, comme
// scripts/verifier_systemes.sh le lance dans ses conteneurs. Seul le nom des
// paramètres régionaux se lit : un nom UTF-8 que l'hôte n'a pas générés
// laisse Chromium sous « C », et l'épreuve de l'OPFS du navigateur nomme
// alors cette cause. C.UTF-8 vient avec la glibc 2.35 et suivantes, et avec
// les distributions qui le livrent avant ; l'environnement reçu reste tel
// quel.
// Un nom de paramètres régionaux dont le jeu de caractères est UTF-8 :
// « fr_CA.UTF-8 », « C.utf8 », « de_DE.UTF-8@euro ».
const NOM_UTF8 = /\.utf-?8(@[^.]*)?$/i;
// Paramètres régionaux de LC_CTYPE que env donne, « C » sans aucun.
function caracteresDe(env) {
for (const variable of ['LC_ALL', 'LC_CTYPE', 'LANG']) {
if (env[variable]) return env[variable];
}
return 'C';
}
export function optionsChromium(env) {
const lancement = {};
if (env.GTT_CHROMIUM) lancement.executablePath = env.GTT_CHROMIUM;
if (!NOM_UTF8.test(caracteresDe(env))) {
lancement.env = { ...env, LANG: 'C.UTF-8' };
delete lancement.env.LC_ALL;
delete lancement.env.LC_CTYPE;
}
return Object.keys(lancement).length > 0 ? { launchOptions: lancement } : {};
} }

View file

@ -2,20 +2,25 @@
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le Chromium des épreuves du navigateur : celui de Playwright par défaut, // Le Chromium des épreuves du navigateur : celui de Playwright par défaut,
// celui que GTT_CHROMIUM désigne sinon, et vitest.config.js applique la règle. // celui que GTT_CHROMIUM désigne sinon ; sous des paramètres régionaux qui ne
// sont pas UTF-8, il reçoit l'environnement sous LANG=C.UTF-8. vitest.config.js
// applique la règle.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import configuration from '../vitest.config.js'; import configuration from '../vitest.config.js';
import { optionsChromium } from './chromium.js'; import { optionsChromium } from './chromium.js';
import { describe, test } from './lanceur.js'; import { describe, test } from './lanceur.js';
// Un environnement d'épreuve, sous des paramètres régionaux UTF-8.
const UTF8 = { PATH: '/usr/bin', LANG: 'fr_CA.UTF-8' };
describe('chromium : l’exécutable des épreuves du navigateur', () => { describe('chromium : l’exécutable des épreuves du navigateur', () => {
test('sans GTT_CHROMIUM, Playwright lance le Chromium qu’il a téléchargé', () => { test('sans GTT_CHROMIUM, Playwright lance le Chromium qu’il a téléchargé', () => {
assert.deepEqual(optionsChromium({}), {}); assert.deepEqual(optionsChromium(UTF8), {});
assert.deepEqual(optionsChromium({ GTT_CHROMIUM: '' }), {}); assert.deepEqual(optionsChromium({ ...UTF8, GTT_CHROMIUM: '' }), {});
}); });
test('GTT_CHROMIUM désigne l’exécutable que Playwright lance', () => { test('GTT_CHROMIUM désigne l’exécutable que Playwright lance', () => {
assert.deepEqual(optionsChromium({ GTT_CHROMIUM: '/nix/store/abc-chromium/bin/chromium' }), { assert.deepEqual(optionsChromium({ ...UTF8, GTT_CHROMIUM: '/nix/store/abc-chromium/bin/chromium' }), {
launchOptions: { executablePath: '/nix/store/abc-chromium/bin/chromium' }, launchOptions: { executablePath: '/nix/store/abc-chromium/bin/chromium' },
}); });
}); });
@ -25,3 +30,44 @@ describe('chromium : l’exécutable des épreuves du navigateur', () => {
assert.deepEqual(navigateur.test.browser.provider.options, optionsChromium(process.env)); assert.deepEqual(navigateur.test.browser.provider.options, optionsChromium(process.env));
}); });
}); });
describe('chromium : les paramètres régionaux du navigateur des épreuves', () => {
test('des paramètres régionaux UTF-8 laissent Chromium hériter de l’environnement : LC_ALL, puis LC_CTYPE, puis LANG décident', () => {
const utf8 = [
{ LANG: 'C.UTF-8' },
{ LANG: 'C.utf8' },
{ LANG: 'de_DE.UTF-8@euro' },
{ LANG: 'C', LC_CTYPE: 'fr_CA.utf8' },
{ LANG: 'C', LC_CTYPE: 'C', LC_ALL: 'fr_CA.UTF-8' },
{ LANG: 'fr_CA.UTF-8', LC_ALL: '', LC_CTYPE: '' },
];
for (const env of utf8) assert.deepEqual(optionsChromium(env), {}, JSON.stringify(env));
});
test('hors UTF-8, Chromium reçoit une copie de l’environnement sous LANG=C.UTF-8, sans LC_ALL ni LC_CTYPE', () => {
const cas = [
[{}, {}],
[{ PATH: '/usr/bin', LC_ALL: 'C' }, { PATH: '/usr/bin' }],
[{ PATH: '/usr/bin', LANG: 'POSIX', LC_MESSAGES: 'fr_CA.UTF-8' }, { PATH: '/usr/bin', LC_MESSAGES: 'fr_CA.UTF-8' }],
[{ LANG: 'fr_CA.UTF-8', LC_CTYPE: 'C' }, {}],
[{ LANG: 'fr_CA.UTF-8', LC_ALL: 'fr_CA.ISO-8859-1' }, {}],
[{ LANG: 'C.UTF-8-ancien' }, {}],
];
for (const [env, reste] of cas) {
const avant = { ...env };
assert.deepEqual(
optionsChromium(env),
{ launchOptions: { env: { ...reste, LANG: 'C.UTF-8' } } },
JSON.stringify(env),
);
assert.deepEqual(env, avant, `${JSON.stringify(env)} : l'environnement reçu reste tel quel`);
}
});
test('hors UTF-8, GTT_CHROMIUM désigne toujours l’exécutable, que l’environnement accompagne', () => {
const env = { LC_ALL: 'C', GTT_CHROMIUM: '/opt/essai/chromium' };
assert.deepEqual(optionsChromium(env), {
launchOptions: { executablePath: '/opt/essai/chromium', env: { GTT_CHROMIUM: '/opt/essai/chromium', LANG: 'C.UTF-8' } },
});
});
});

View file

@ -57,18 +57,23 @@ function dossierTemporaire() {
} }
// test du lanceur, dont chaque épreuve retire à sa fin les dossiers // test du lanceur, dont chaque épreuve retire à sa fin les dossiers
// temporaires qu'elle a ouverts. // temporaires qu'elle a ouverts ; le corps reçoit le contexte de l'épreuve.
function testNettoye(nom, ...suite) { function testNettoye(nom, ...suite) {
const corps = suite.pop(); const corps = suite.pop();
test(nom, ...suite, async () => { test(nom, ...suite, async (contexte) => {
try { try {
await corps(); await corps(contexte);
} finally { } finally {
for (const dossier of ouverts.splice(0)) rmSync(dossier, { recursive: true, force: true }); for (const dossier of ouverts.splice(0)) rmSync(dossier, { recursive: true, force: true });
} }
}); });
} }
// Ce que dit une commande lancée par spawnSync : l'exception qui l'a
// empêchée de démarrer, sinon sa sortie d'erreur. Toujours une chaîne,
// seul message que node:assert accepte.
const ditPar = (lancee) => String(lancee.error?.message ?? lancee.stderr ?? '');
// Charge electron/preload.cjs comme un rendu en bac à sable, contre un // Charge electron/preload.cjs comme un rendu en bac à sable, contre un
// electron dont invoke porte chaque appel au gestionnaire que servir a posé // electron dont invoke porte chaque appel au gestionnaire que servir a posé
// sur ce canal ; arguments et réponse sont copiés comme l'IPC les copie, et // sur ce canal ; arguments et réponse sont copiés comme l'IPC les copie, et
@ -350,7 +355,14 @@ describe('coquille sur un vrai disque', () => {
testNettoye( testNettoye(
"la lecture par ps (macOS) passe TZ=UTC et LC_ALL=C à la commande : le démarrage inscrit est en temps universel, et un processus d'un autre fuseau lit la séance vivante", "la lecture par ps (macOS) passe TZ=UTC et LC_ALL=C à la commande : le démarrage inscrit est en temps universel, et un processus d'un autre fuseau lit la séance vivante",
{ skip: SOUS_WINDOWS }, { skip: SOUS_WINDOWS },
async () => { async (contexte) => {
// L'épreuve joue macOS avec le ps de l'hôte. Sous Linux, la coquille
// lit /proc et ne demande pas ps, qu'une image de système minimale ne
// porte pas : sans lui, l'épreuve se saute et dit pourquoi.
if (spawnSync('ps', ['-p', String(process.pid)]).error?.code === 'ENOENT') {
contexte.skip("ps introuvable sur ce système : la lecture du démarrage par ps ne s'y éprouve pas");
return;
}
const documents = join(dossierTemporaire(), 'Documents'); const documents = join(dossierTemporaire(), 'Documents');
mkdirSync(documents, { recursive: true }); mkdirSync(documents, { recursive: true });
const fichiers = creerFichiers({ const fichiers = creerFichiers({
@ -366,7 +378,7 @@ describe('coquille sur un vrai disque', () => {
env: { ...process.env, LC_ALL: 'C', TZ: 'UTC' }, env: { ...process.env, LC_ALL: 'C', TZ: 'UTC' },
encoding: 'utf8', encoding: 'utf8',
}); });
assert.equal(enUtc.status, 0, enUtc.stderr); assert.equal(enUtc.status, 0, ditPar(enUtc));
assert.equal(pose.demarrage, enUtc.stdout.trim()); assert.equal(pose.demarrage, enUtc.stdout.trim());
// Un second processus, dans un fuseau qui n'est pas celui de // Un second processus, dans un fuseau qui n'est pas celui de
@ -387,7 +399,7 @@ describe('coquille sur un vrai disque', () => {
}, },
encoding: 'utf8', encoding: 'utf8',
}); });
assert.equal(relu.status, 0, relu.stderr); assert.equal(relu.status, 0, ditPar(relu));
assert.deepEqual(JSON.parse(relu.stdout), { assert.deepEqual(JSON.parse(relu.stdout), {
fuseau: autre, fuseau: autre,
tenu: { pris: false, seance: 'seance-a', depuis: pose.depuis, vivant: true }, tenu: { pris: false, seance: 'seance-a', depuis: pose.depuis, vivant: true },