Compare commits

..

No commits in common. "82e57f47df5e5d8eac27bd2983c0f9eab411b0ad" and "8d03d5ba410bc5fad141e67d531f276cca2884d8" have entirely different histories.

80 changed files with 988 additions and 7405 deletions

View file

@ -17,12 +17,6 @@
# 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.
#
# 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
# journaux vont dans GTT_JOURNAUX, un dossier temporaire par défaut. Les
# téléchargements — npm, Electron, Chromium, Node — se partagent entre
@ -109,32 +103,24 @@ rm -rf "$source_copie"
mkdir -p "$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, ps quand l'image ne le porte
# pas — procps-ng chez Fedora, AlmaLinux, Rocky et Arch, procps ailleurs —,
# et un compte ordinaire « dev » qui emploie sudo sans mot de passe, comme un
# poste de développement. Son uid est le même dans chaque conteneur — 1500,
# libre aussi sous Ubuntu, dont l'image donne 1000 à son propre compte — :
# les caches partagés ne changent jamais de propriétaire.
# Préparation sous root, par famille : sudo et un compte ordinaire « dev »
# qui l'emploie sans mot de passe, comme un poste de développement. Son uid
# est le même dans chaque conteneur — 1500, libre aussi sous Ubuntu, dont
# l'image donne 1000 à son propre compte — : les caches partagés ne
# changent jamais de propriétaire.
preparation() {
case "$1" in
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 $(command -v ps > /dev/null || echo procps-ng)' ;;
opensuse-*) echo 'zypper --non-interactive install sudo shadow $(command -v ps > /dev/null || echo procps)' ;;
arch) echo 'pacman -Syu --noconfirm sudo $(command -v ps > /dev/null || echo procps-ng)' ;;
ubuntu-* | debian-*) echo 'apt-get update -q && DEBIAN_FRONTEND=noninteractive apt-get install -y -q sudo' ;;
fedora-* | almalinux-* | rocky-*) echo 'dnf install -y sudo shadow-utils' ;;
opensuse-*) echo 'zypper --non-interactive install sudo shadow' ;;
arch) echo 'pacman -Syu --noconfirm sudo' ;;
esac
}
# É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
# 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.
# que sur une installation réussie.
ETAPES_DEV='
export LANG=C.UTF-8
etape() { local nom="$1"; shift; if "$@"; then echo "=== $nom ok"; else echo "=== $nom ÉCHEC"; return 1; fi; }
cd "$HOME/projet"
etape install_dev ./install_dev.sh || exit 1

View file

@ -1565,12 +1565,7 @@ régénérable.
Puis, **par tour** : la liste des tables, chacune étant la liste des
**identifiants entiers** de ses occupants **dans l'ordre des sièges** ; et la
**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. 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`.
siège n'est pas stocké : il est la position dans la liste.
**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

View file

@ -30,15 +30,28 @@ import { serialiserCharge } from '../stockage/canonique.js';
import { SCHEMA, clesRangees, creerCharge, etatDeduit, premiereFaute } from '../stockage/document.js';
import { ErreurStockage } from '../stockage/erreurs.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 { nomAffiche } from './personnes.js';
// Le refus d'une commande vit dans erreurs.js, le nom affiché d'une personne
// dans personnes.js : les importateurs de ce module les y trouvent encore.
export { ErreurCommande, nomAffiche };
/**
* 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.
const refus = (code, details = {}, remede = null) => new ErreurCommande(code, { ...details, remede });
// Un avertissement : il n'empêche rien.
const avertissement = (code, details) => ({ code, details });
@ -107,6 +120,9 @@ function participantDe(charge, id) {
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).
function exigerModifiable(charge) {
if (charge.evenement.etat === 'bloque') throw refus('PLAN_BLOQUE', {}, { geste: 'debloquer' });
@ -155,7 +171,7 @@ function ajouterParticipant(charge, { champs }) {
suivante.prochainsIds.participant = id + 1;
return {
charge: suivante,
libelle: libelle('ENTREE_AJOUTER_PARTICIPANT', { personne: nomAffiche(participant) }),
libelle: libelle('ENTREE_AJOUTER_PARTICIPANT', { personne: personneDe(participant) }),
avertissements: [],
};
}
@ -176,9 +192,9 @@ function modifierParticipant(charge, { id, champs }) {
return {
charge: avecParticipant(charge, id, () => apres),
libelle: libelle('ENTREE_MODIFIER_PARTICIPANT', {
personne: nomAffiche(avant),
personne: personneDe(avant),
champs: changes,
devenu: renomme ? nomAffiche(apres) : null,
devenu: renomme ? personneDe(apres) : null,
}),
avertissements: [],
};
@ -190,7 +206,7 @@ function modifierParticipant(charge, { id, champs }) {
function bilanExclusion(avant, apres, participant) {
const reservations = avant.reservations.filter((reservation) => reservation.participant === participant.id).length;
const titres = Math.abs(titresPourvus(avant) - titresPourvus(apres));
return { participant: participant.id, personne: nomAffiche(participant), reservations, titres };
return { participant: participant.id, personne: personneDe(participant), reservations, titres };
}
/**
@ -239,7 +255,7 @@ function supprimerParticipant(charge, { id }) {
const reservations = charge.reservations.length - suivante.reservations.length;
const details = {
participant: id,
personne: nomAffiche(participant),
personne: personneDe(participant),
reservations,
titres: titresPourvus(charge) - titresPourvus(suivante),
};
@ -407,52 +423,28 @@ 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) →
* { charge, libelle, avertissements }, et importerParticipants y ajoute le
* résumé de l'import. Il réunit les commandes de ce module, celles du
* mobilier et celles des places.
* résumé de l'import.
*
* @typedef {{code: string, details: Object}} Avertissement
* @typedef {{charge: Object, libelle: string, avertissements: Avertissement[]}} Rendu
* @type {Map<string, (charge: Object, args: Object) => Rendu>}
*/
export const COMMANDES = reunirCommandes(
new Map([
['ajouterParticipant', ajouterParticipant],
['modifierParticipant', modifierParticipant],
['exclureParticipant', exclureParticipant],
['reintegrerParticipant', reintegrerParticipant],
['supprimerParticipant', supprimerParticipant],
['importerParticipants', importerParticipants],
['changerEtat', changerEtat],
['debloquer', debloquer],
['enregistrerGeneration', enregistrerGeneration],
['retenirProposition', retenirProposition],
['effacerPropositions', effacerPropositions],
]),
COMMANDES_MOBILIER,
COMMANDES_PLACES,
);
export const COMMANDES = new Map([
['ajouterParticipant', ajouterParticipant],
['modifierParticipant', modifierParticipant],
['exclureParticipant', exclureParticipant],
['reintegrerParticipant', reintegrerParticipant],
['supprimerParticipant', supprimerParticipant],
['importerParticipants', importerParticipants],
['changerEtat', changerEtat],
['debloquer', debloquer],
['enregistrerGeneration', enregistrerGeneration],
['retenirProposition', retenirProposition],
['effacerPropositions', effacerPropositions],
]);
/**
* Applique la commande nom à la charge : ce qu'elle rend, avertissements []

View file

@ -14,12 +14,7 @@ import { rechercher } from '../moteur/recherche.js';
import { configurationDepuisCharge, creerCharge, etatDeduit } from '../stockage/document.js';
import { derive, fautes, formeNommee, planDepuisFichier } from '../stockage/placements.js';
import { VERSION } from '../version.genere.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';
import { COMMANDES, ErreurCommande, appliquerCommande, chargeNeuve, formeNommeeDe } from './commandes.js';
const V1 = VERSION.affichee;
@ -608,61 +603,3 @@ 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

@ -1,9 +0,0 @@
// © 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

@ -1,9 +0,0 @@
// © 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();

View file

@ -1,29 +0,0 @@
// © 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

@ -1,64 +0,0 @@
// © 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

@ -1,36 +0,0 @@
// © 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 });
}

View file

@ -1,149 +0,0 @@
// © 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

@ -1,177 +0,0 @@
// © 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');
});
});

View file

@ -1,193 +0,0 @@
// © 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

@ -1,187 +0,0 @@
// © 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,70 +1,657 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// L'assemblage des textes de l'application (§ 14.6) : chaque code que lèvent
// ou rendent src/stockage, src/csv et src/application — refus, avertissements,
// raisons, causes et remèdes de leurs détails —, chaque libellé figé d'une
// entrée ou d'un jalon du journal, et les textes de la mise en forme ont leur
// texte français dans l'une des tables de libelles/, une par domaine. Ce
// module les réunit par le mécanisme de traduction (traduction.js), dans
// l'ordre alphabétique de leurs fichiers ; aucune table ne l'importe.
//
// Un code sans texte lève : l'écran ne montre jamais un code brut. Une valeur
// codée d'un détail — état, champ, mode, échec — que la table ne connaît pas
// lève aussi, au lieu de se taire.
// La table des textes de l'application (§ 14.6) : chaque code que lèvent 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
// entrée ou d'un jalon du journal a ici son texte français, qu'une fonction
// compose de ses détails. Un code sans texte lève : l'écran ne montre jamais
// un code brut. Une valeur codée d'un détail — état, champ, mode, échec —
// que la table ne connaît pas lève aussi, au lieu de se taire. Une raison
// 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
// 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 ; 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
// journal en garde le texte, jamais le code (§ 8.2) : changer un texte ici ne
// 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
// passe jamais par la langue témoin.
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';
// change aucun libellé déjà écrit.
//
// Les textes 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.
/** Les tables de libelles/, dans l'ordre alphabétique de leurs fichiers. */
export const TABLES_APPLICATION = Object.freeze([
CSV,
EVENEMENTS,
FORMAT,
GENERATION,
MOBILIER,
MOTEUR,
PARTICIPANTS,
PLACES,
SEANCE,
STOCKAGE,
// Deux chaînes, comparées unité UTF-16 par unité.
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
const NOMS_DES_ETATS = new Map([
['brouillon', 'Brouillon'],
['propose', 'Proposé'],
['retenu', 'Retenu'],
['bloque', 'Bloqué'],
]);
const FRANCAIS = creerTraduction(TABLES_APPLICATION);
// 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'],
]);
// Un code : des capitales, des chiffres, des soulignés entre deux mots. Un
// libellé, préfixé du nom de sa table, porte au moins un point.
const FORME_CODE = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/;
const MOIS = [
'janvier',
'février',
'mars',
'avril',
'mai',
'juin',
'juillet',
'août',
'septembre',
'octobre',
'novembre',
'décembre',
];
/** Les clés de forme code, seules, triées par unités UTF-16 croissantes. */
export const CODES = Object.freeze(FRANCAIS.cles().filter((cle) => FORME_CODE.test(cle)));
// Valeur d'une table de noms, ou RangeError qui la nomme.
function nomDans(table, valeur, quoi) {
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'une clé et de ses détails (§ 14.6) : un refus, un
* avertissement, une raison, le libellé figé d'une entrée ou d'un jalon, ou
* un libellé de l'application.
* Le texte français d'un code 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.
*
* @param {string} cle
* @param {string} code
* @param {Object} [details]
* @returns {string}
* @throws {RangeError} une clé sans texte, ou une valeur codée de ses détails
* @throws {RangeError} un code sans texte, ou une valeur codée de ses détails
* — état, champ, mode, échec — que la table ne connaît pas ; jamais
* une raison, qu'une phrase générique nomme
*/
export function libelle(cle, details = {}) {
return FRANCAIS.t(cle, details);
export function libelle(code, details = {}) {
return texteDe(code, details);
}

View file

@ -12,27 +12,20 @@
// 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
// 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 { readdirSync, readFileSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, test } from '../../test/lanceur.js';
import { CODES, TABLES_APPLICATION, libelle } from './libelles.js';
import { CODES, libelle } from './libelles.js';
const RACINE = fileURLToPath(new URL('../..', import.meta.url));
// Les arbres balayés, et les fichiers qui portent une table de codes.
const ARBRES = ['src/stockage', 'src/csv', 'src/application'];
const TABLES = ['src/stockage/types.js', 'src/csv/erreurs.js'];
// 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.
// La table des textes elle-même n'émet rien : ses clés sont ce qu'on éprouve.
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 ----------------------------------------------------------
@ -86,7 +79,7 @@ function codesDesTables(texte) {
function modulesDe(arbre) {
return readdirSync(join(RACINE, arbre), { recursive: true })
.map((nom) => join(arbre, nom))
.filter((chemin) => chemin.endsWith('.js') && !/\.test\.js$/.test(chemin) && horsDesTextes(chemin))
.filter((chemin) => chemin.endsWith('.js') && !/\.test\.js$/.test(chemin) && chemin !== TABLE_DES_TEXTES)
.sort();
}
@ -165,16 +158,6 @@ 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', () => {
const { parArbre, tables } = releverArbre();
for (const arbre of ARBRES) assert.ok(parArbre.get(arbre).size > 0, `${arbre} : balayage vide`);
@ -266,7 +249,6 @@ const EXEMPLES = new Map([
['DONNEES_APPLICATIVES', {}],
['SONDE_ECHOUEE', { cause: 'EROFS' }],
['RELECTURE_DIFFERENTE', {}],
['NOM_NON_CONVERTI', {}],
['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' }],
['PLATEFORME_WEB', {}],
@ -543,70 +525,3 @@ describe('libellés : les textes (§ 14.6, § 8.2)', () => {
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

@ -1,102 +0,0 @@
// © 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

@ -1,62 +0,0 @@
// © 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

@ -1,22 +0,0 @@
// © 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

@ -1,60 +0,0 @@
// © 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

@ -1,26 +0,0 @@
// © 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

@ -1,5 +0,0 @@
// © 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

@ -1,6 +0,0 @@
// © 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

@ -1,80 +0,0 @@
// © 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

@ -1,6 +0,0 @@
// © 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

@ -1,98 +0,0 @@
// © 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

@ -1,319 +0,0 @@
// © 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

@ -1,15 +0,0 @@
// © 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

@ -1,33 +0,0 @@
// © 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

@ -1,73 +0,0 @@
// © 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

@ -1,111 +0,0 @@
// © 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);
});
});

View file

@ -1,158 +0,0 @@
// © 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

@ -1,96 +0,0 @@
// © 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

@ -1,75 +0,0 @@
// © 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

@ -1,188 +0,0 @@
// © 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);
});
});

View file

@ -1,40 +0,0 @@
// © 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

@ -1,55 +0,0 @@
// © 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}`);
}
});
});

View file

@ -1,239 +0,0 @@
// © 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

@ -1,331 +0,0 @@
// © 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);
});
});

View file

@ -1,75 +0,0 @@
// © 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

@ -1,92 +0,0 @@
// © 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);
});
});

View file

@ -1,218 +0,0 @@
// © 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]) };
}

View file

@ -1,348 +0,0 @@
// © 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,22 +9,21 @@
// contraste se calcule sur les couleurs calculées, qui ne sont les couleurs
// peintes que sans opacité partielle, filtre ni mode de fusion — ce qu'une
// épreuve vérifie dans l'ascendance de chaque texte (§ 14.5). Les épreuves de
// visibilité, de contraste et d'opacité passent par les deux thèmes, que
// l'attribut data-theme de <html> choisit (§ 19.2). Le contraste est celui de
// palette.js, seule formule des WCAG du dépôt.
// visibilité, de contraste et d'opacité passent par les deux thèmes : le
// protocole de débogage de Chromium (CDP) émule prefers-color-scheme.
//
// Le projet navigateur n'importe pas node:assert/strict : côté navigateur,
// Vite le remplace par un module qui lève une erreur à tout accès. Il emploie
// expect, et prend test et describe chez Vitest plutôt que dans
// test/lanceur.js, dont le repli vers node:test ne vaut que pour la série node.
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { cdp } from 'vitest/browser';
import { flushSync, mount, unmount } from 'svelte';
import App from './App.svelte';
import { contraste } from './palette.js';
import { VERSION } from '../version.genere.js';
const TRANSPARENT = 'rgba(0, 0, 0, 0)';
const THEMES = ['clair', 'sombre'];
const THEMES = ['light', 'dark'];
// Composantes [r, g, b, a] d'une couleur calculée. Chromium la rend sous la
// forme « rgb(r, g, b) » ou « rgba(r, g, b, a) » quand la source est une
@ -39,6 +38,21 @@ function composantes(couleur) {
return [Number(m[1]), Number(m[2]), Number(m[3]), alpha];
}
// Luminance relative d'une couleur sRGB, au sens des WCAG.
function luminance([r, g, b]) {
const lineaire = (c) => {
const s = c / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * lineaire(r) + 0.7152 * lineaire(g) + 0.0722 * lineaire(b);
}
function contraste(a, b) {
const la = luminance(a);
const lb = luminance(b);
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}
// L'élément, puis chacun de ses ancêtres jusqu'à la racine du document.
function ascendance(element) {
const chaine = [];
@ -75,21 +89,20 @@ function designation(element) {
return classe ? `${element.localName}.${classe}` : element.localName;
}
// Exécute mesure() sous le thème demandé, posé par data-theme sur <html>, et
// rend sa valeur. L'attribut retrouve ensuite sa valeur d'avant, ou son
// absence, même quand mesure() lève : le test suivant part du même document.
// Exécute mesure() sous le thème demandé et rend sa valeur. L'émulation est
// levée ensuite, même quand mesure() échoue : le test suivant retrouve la
// préférence propre du navigateur.
async function sousTheme(theme, mesure) {
const racine = document.documentElement;
const avant = racine.getAttribute('data-theme');
racine.setAttribute('data-theme', theme);
await cdp().send('Emulation.setEmulatedMedia', {
features: [{ name: 'prefers-color-scheme', value: theme }],
});
try {
return await mesure();
expect(matchMedia('(prefers-color-scheme: dark)').matches, theme).toBe(
theme === 'dark',
);
return mesure();
} finally {
if (avant === null) {
racine.removeAttribute('data-theme');
} else {
racine.setAttribute('data-theme', avant);
}
await cdp().send('Emulation.setEmulatedMedia', { features: [] });
}
}
@ -197,8 +210,8 @@ describe("bandeau d'application", () => {
});
couleursParTheme.push(couleurs);
}
// Un attribut que la feuille ne lit pas, ou un thème sombre qui ne
// redéfinit aucune couleur, ferait mesurer deux fois le thème clair.
// Une émulation sans effet, ou un thème sombre qui ne redéfinit aucune
// couleur, ferait mesurer deux fois le thème clair.
expect(
couleursParTheme[1],
`mêmes couleurs dans les deux thèmes : ${couleursParTheme[0].join(' ; ')}`,

View file

@ -1,144 +1,42 @@
/* © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) */
/* Jetons de l'interface, un seul fichier (§ 13.3) : couleurs, polices,
échelle typographique, espacements, rayons, ombres. Les composants ne lisent
que ces jetons ; aucune couleur n'est écrite ailleurs.
/* Jetons de l'interface : couleurs, typographie, espacements, rayons. Le thème
suit prefers-color-scheme ; chaque thème redéfinit les mêmes jetons de
couleur, et les composants ne lisent que les jetons (§ 13.3).
Deux thèmes, posés par l'attribut data-theme de <html>, que l'application
règle (§ 19.2) : :root[data-theme='clair'] et :root[data-theme='sombre']
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. */
Chaque couleur de texte tient au moins 4,5:1 sur le fond où elle est peinte,
et aucune n'emploie de canal alpha : la couleur relue dans le style calculé
est alors la couleur peinte (§ 14.5). */
:root {
--police-interface: system-ui, sans-serif;
color-scheme: light;
/* Échelle typographique, du plus petit au plus grand. */
--taille-texte-petit: 0.8125rem;
--couleur-page-fond: #f6f7f9;
--couleur-page-texte: #1c2128;
--couleur-bandeau-fond: #1f3a5f;
--couleur-bandeau-texte: #ffffff;
--couleur-bandeau-filet: #8fb3d9;
--police-interface: system-ui, sans-serif;
--taille-texte-bandeau: 0.9375rem;
--taille-texte: 1rem;
--taille-titre: 1.25rem;
--taille-titre-grand: 1.5rem;
--espace-1: 0.25rem;
--espace-2: 0.5rem;
--espace-3: 0.75rem;
--espace-4: 1rem;
--espace-5: 1.5rem;
--espace-6: 2rem;
--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);
}
:root,
:root[data-theme='clair'] {
color-scheme: light;
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
/* Page. */
--couleur-page-fond: #f6f7f9;
--couleur-page-texte: #1c2128;
--couleur-page-texte-secondaire: #4b5563;
--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;
--couleur-page-fond: #121519;
--couleur-page-texte: #e3e6ea;
--couleur-bandeau-fond: #1d2c3f;
--couleur-bandeau-texte: #f2f4f7;
--couleur-bandeau-filet: #5b7fa6;
}
}

View file

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

View file

@ -5,16 +5,10 @@
// libellé de provenance, comme le mode test que pose Vitest ; un mode sans
// libellé s'affiche sous son propre nom plutôt que de laisser le bandeau muet
// sur la provenance.
//
// 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 { readdirSync, readFileSync } from 'node:fs';
import { TABLES_APPLICATION } from '../application/libelles.js';
import { creerTraduction } from '../application/traduction.js';
import { readFileSync } from 'node:fs';
import { describe, test } from '../../test/lanceur.js';
import { TABLES_INTERFACE, aUnLibelle, libelleProvenance, titreAvecVersion } from './libelles.js';
import { aUnLibelle, libelleProvenance, titreAvecVersion } from './libelles.js';
// Modes nommés par les scripts de package.json, triés, sans doublon.
function modesDesScripts() {
@ -54,54 +48,3 @@ describe('libellés', () => {
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

@ -1,7 +0,0 @@
// © 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

@ -1,6 +0,0 @@
// © 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

@ -1,14 +0,0 @@
// © 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

@ -1,6 +0,0 @@
// © 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

@ -1,7 +0,0 @@
// © 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

@ -1,6 +0,0 @@
// © 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

@ -1,6 +0,0 @@
// © 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

@ -1,6 +0,0 @@
// © 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

@ -1,6 +0,0 @@
// © 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

@ -1,6 +0,0 @@
// © 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

@ -1,6 +0,0 @@
// © 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();

View file

@ -1,74 +0,0 @@
// © 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

@ -1,132 +0,0 @@
// © 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), []);
});
});

View file

@ -1,292 +0,0 @@
// © 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

@ -1,356 +0,0 @@
// © 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']), []);
});
});

View file

@ -1,66 +0,0 @@
// © 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

@ -1,227 +0,0 @@
// © 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));
});
});

View file

@ -1,297 +0,0 @@
// © 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,11 +14,6 @@
// configuration.js, plafond a priori de plafond.js, retours imposés de la
// mesure d'indicateurs.js. Le diagnostic ne modifie pas la configuration et
// lève ce que normaliser lève.
//
// 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
@ -28,39 +23,21 @@
* @property {number} plancherPairesDistinctes
* @property {number|null} plancherExcedent null quand il n'est pas
* 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
* @property {number} placesManquantes max(0, N − Σ c_t), par tour
* @property {{min: number|null, max: number|null, parPersonne: number[],
* repartition: Array<{valeur: number, ancres: number, mobiles: number}>}} plafondAPriori
* @property {{min: number|null, max: number|null, parPersonne: number[]}} plafondAPriori
* parPersonne dans l'ordre de instance.ids ; min et max null pour
* une population vide ; repartition, une ligne par valeur
* présente, valeurs croissantes, les partiellement fixés parmi les
* mobiles
* une population vide
* @property {PlancherCollisions[]} collisions groupes dont le plancher de
* collisions cumulées est > 0, dans l'ordre de instance.groupes
* @property {number|null} redondanceMinimale null sans affilié
* @property {{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 {number} redondanceImposeeParAnimateurs min(R, T) − 1 ou 0
* @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
* @property {Array<{table: number, ancres: number, capacite: number, gelee: boolean}>} ancrage
* chaque table, par identifiant, dans l'ordre de la configuration ;
* gelee quand ses ancrés occupent tous ses sièges ; moitie quand ils
* en occupent la moitié au moins, sans la geler
* gelee quand ses ancrés occupent tous ses sièges
* @property {number} retoursImposes
* @property {number} ancragesImposantRetours personnes dont les réservations
* imposent un retour
* @property {{plancher: number, mobilesAuMoins: number}|null} ecartItineraire
*/
import {
@ -177,110 +154,14 @@ function animateursMemeAppartenance({ N, statut, groupe }) {
// Ancrés et capacité de chaque table, dans l'ordre de l'instance, qui est
// celui de la configuration ; gelee quand les ancrés occupent tous les sièges
// (§ 5.9) ; moitie quand ils en occupent la moitié au moins, 2 × a_t ≥ c_t,
// sur une table qui tourne encore.
// (§ 5.9).
function ancrage({ T, idsTables, capacite, ancresParTable }) {
return Array.from({ length: T }, (_, t) => {
const gelee = ancresParTable[t] === capacite[t];
return {
table: idsTables[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;
return Array.from({ length: T }, (_, t) => ({
table: idsTables[t],
ancres: ancresParTable[t],
capacite: capacite[t],
gelee: ancresParTable[t] === capacite[t],
}));
}
// Plafond du meilleur itinéraire d'un mobile p qui passe par la table t : le
@ -368,23 +249,19 @@ function etendue(valeurs) {
*
* - placesManquantes : nombrePlacesManquantes ; la recherche refuse une
* configuration où elles sont positives, le diagnostic les chiffre (§ 5.9).
* - plafondAPriori : plafondsAPriori, son étendue et sa répartition par
* valeur.
* - plafondAPriori : plafondsAPriori, et son étendue.
* - collisions : planchers de collisions par groupe, posés sur les tours
* fixés, capacités relâchées ; chacun avec les tables qui l'annulent.
* fixés, capacités relâchées.
* - redondanceMinimale : max_p max(0, min(a priori(p), affiliés autres que
* p) − G) ; remedes.toursSansRedondance, le nombre de tours qui l'annule.
* p) − G).
* - animateursMemeAppartenance, et redondanceImposeeParAnimateurs : la forme
* close min(R, T) − 1 du § 5.6, qui compte un animateur à chaque table
* visitée ; 0 quand les animateurs ne partagent pas une appartenance.
* - 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.
* - ancrage : ancrés, capacité et gel de chaque table.
* - retoursImposes : la rangée de instance.fixe se lit comme le plan qui
* assied chacun aux seuls tours fixés, en réserve ailleurs ; sa mesure
* compte max(0, f − 1) retours pour f tours réservés à une même table, ce
* que mesure tout plan qui honore les réservations (§ 5.4) ;
* ancragesImposantRetours, les personnes qui en portent un au moins.
* que mesure tout plan qui honore les réservations (§ 5.4).
* - ecartItineraire : plancher de l'écart d'itinéraire maximal, ou null.
*
* @param {import('./types.js').Configuration} configuration
@ -394,26 +271,15 @@ export function diagnostiquer(configuration) {
const instance = normaliser(configuration);
const aPriori = plafondsAPriori(instance);
const memeAppartenance = animateursMemeAppartenance(instance);
const placesManquantes = nombrePlacesManquantes(instance);
const minimale = redondanceMinimale(instance, aPriori);
const imposes = mesurer(instance, instance.fixe);
return {
placesManquantes,
plafondAPriori: {
...etendue(aPriori),
parPersonne: aPriori,
repartition: repartition(aPriori, instance.statut),
},
collisions: avecTablesManquantes(configuration, instance, planchersCollisions(instance)),
redondanceMinimale: minimale,
remedes: { toursSansRedondance: toursSansRedondance(configuration, instance.R, minimale) },
placesManquantes: nombrePlacesManquantes(instance),
plafondAPriori: { ...etendue(aPriori), parPersonne: aPriori },
collisions: planchersCollisions(instance),
redondanceMinimale: redondanceMinimale(instance, aPriori),
animateursMemeAppartenance: memeAppartenance,
redondanceImposeeParAnimateurs: memeAppartenance ? Math.min(instance.R, instance.T) - 1 : 0,
uneSeuleAppartenance: uneSeuleAppartenance(instance),
collisionPartout: collisionPartout(instance, placesManquantes),
ancrage: ancrage(instance),
retoursImposes: imposes.totalRetoursImposes,
ancragesImposantRetours: imposes.retoursImposes.filter((retours) => retours > 0).length,
retoursImposes: mesurer(instance, instance.fixe).totalRetoursImposes,
ecartItineraire: ecartItineraire(configuration, instance, aPriori),
};
}

View file

@ -11,7 +11,6 @@
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 { LIBRE, STATUT, indexerPlan, normaliser } from './configuration.js';
import { diagnostiquer } from './diagnostic.js';
import { ErreurConfiguration } from './erreurs.js';
@ -24,31 +23,15 @@ const SANS_CONTRAINTE = {
nouvelleTable: 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
* 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 ;
* contraintes, aucune par défaut.
* appartenances[j] est celle du participant d'id j + 1 ; exclus nomme des ids.
* Les identifiants de table diffèrent des index et des numéros : un index lu
* comme un identifiant désigne une table absente.
*/
function salle({
tables,
appartenances,
tours,
reservations = [],
exclus = [],
contraintes = SANS_CONTRAINTE,
}) {
function salle({ tables, appartenances, tours, reservations = [], exclus = [] }) {
return {
participants: appartenances.map((appartenance, j) => ({
id: j + 1,
@ -59,7 +42,7 @@ function salle({
tables: tables.map(([id, capacite], i) => ({ id, numero: i + 1, capacite })),
tours,
reservations,
contraintes: { ...contraintes },
contraintes: SANS_CONTRAINTE,
};
}
@ -234,20 +217,8 @@ describe('diagnostic : le catalogue (§ 15)', () => {
ancres: 1,
capacite: t < 29 ? 8 : 7,
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.
@ -279,7 +250,6 @@ describe('diagnostic : le catalogue (§ 15)', () => {
plancherCumulees: 4,
plancherPairesDistinctes: 1,
plancherExcedent: null,
tablesManquantes: 1,
},
]);
});
@ -433,7 +403,6 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
plancherCumulees: 5,
plancherPairesDistinctes: 1,
plancherExcedent: 2,
tablesManquantes: 1,
},
]);
assert.deepEqual(minimumsParGroupe(configuration).get('X'), {
@ -461,14 +430,7 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
tours: 4,
});
assert.deepEqual(diagnostiquer(configuration).collisions, [
{
groupe: 'X',
effectif: 6,
plancherCumulees: 8,
plancherPairesDistinctes: 2,
plancherExcedent: null,
tablesManquantes: 2,
},
{ groupe: 'X', effectif: 6, plancherCumulees: 8, plancherPairesDistinctes: 2, plancherExcedent: null },
]);
});
@ -479,14 +441,7 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
tours: 2,
});
assert.deepEqual(diagnostiquer(configuration).collisions, [
{
groupe: 'X',
effectif: 6,
plancherCumulees: 12,
plancherPairesDistinctes: 6,
plancherExcedent: null,
tablesManquantes: 4,
},
{ groupe: 'X', effectif: 6, plancherCumulees: 12, plancherPairesDistinctes: 6, plancherExcedent: null },
]);
const exacts = minimumsParGroupe(configuration).get('X');
assert.equal(exacts.cumulees, 12);
@ -500,14 +455,7 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
tours: 3,
});
assert.deepEqual(diagnostiquer(configuration).collisions, [
{
groupe: 'X',
effectif: 5,
plancherCumulees: 12,
plancherPairesDistinctes: 4,
plancherExcedent: 2,
tablesManquantes: 3,
},
{ groupe: 'X', effectif: 5, plancherCumulees: 12, plancherPairesDistinctes: 4, plancherExcedent: 2 },
]);
assert.deepEqual(minimumsParGroupe(configuration).get('X'), { cumulees: 12, distinctes: 4, excedent: 2 });
});
@ -525,7 +473,6 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
plancherCumulees: 4,
plancherPairesDistinctes: 1,
plancherExcedent: 1,
tablesManquantes: 1,
},
]);
assert.deepEqual(minimumsParGroupe(configuration).get('X'), {
@ -555,7 +502,6 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
plancherCumulees: 3,
plancherPairesDistinctes: 1,
plancherExcedent: null,
tablesManquantes: null,
},
{
groupe: 'Y',
@ -563,7 +509,6 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
plancherCumulees: 1,
plancherPairesDistinctes: 1,
plancherExcedent: null,
tablesManquantes: null,
},
]);
const minimums = minimumsParGroupe(configuration);
@ -587,7 +532,6 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
plancherCumulees: 3,
plancherPairesDistinctes: 1,
plancherExcedent: 2,
tablesManquantes: null,
},
]);
});
@ -608,7 +552,6 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
plancherCumulees: 2,
plancherPairesDistinctes: 1,
plancherExcedent: null,
tablesManquantes: 1,
},
]);
});
@ -990,9 +933,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)],
});
assert.deepEqual(diagnostiquer(configuration).ancrage, [
{ table: 30, ancres: 2, capacite: 2, gelee: true, moitie: false },
{ table: 10, ancres: 2, capacite: 3, gelee: false, moitie: true },
{ table: 20, ancres: 0, capacite: 2, gelee: false, moitie: false },
{ table: 30, ancres: 2, capacite: 2, gelee: true },
{ table: 10, ancres: 2, capacite: 3, gelee: false },
{ table: 20, ancres: 0, capacite: 2, gelee: false },
]);
});
@ -1021,8 +964,6 @@ describe('diagnostic : ancrage et retours imposés (§ 5.4, § 5.6)', () => {
});
const diagnostic = diagnostiquer(configuration);
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(
diagnostic.ancrage.map(({ ancres }) => ancres),
[2, 0],
@ -1057,34 +998,25 @@ describe('diagnostic : population vide, forme, refus', () => {
test('une population vide rend null, jamais 0, là où rien ne se mesure', () => {
const attendu = {
placesManquantes: 0,
plafondAPriori: { min: null, max: null, parPersonne: [], repartition: [] },
plafondAPriori: { min: null, max: null, parPersonne: [] },
collisions: [],
redondanceMinimale: null,
remedes: { toursSansRedondance: null },
animateursMemeAppartenance: false,
redondanceImposeeParAnimateurs: 0,
uneSeuleAppartenance: false,
collisionPartout: false,
ancrage: [{ table: 10, ancres: 0, capacite: 4, gelee: false, moitie: false }],
ancrage: [{ table: 10, ancres: 0, capacite: 4, gelee: false }],
retoursImposes: 0,
ancragesImposantRetours: 0,
ecartItineraire: null,
};
// Les mêmes chiffres, séparation active ou non : sans présent, aucune
// appartenance n'est portée par chacun, et aucune table n'est forcée.
for (const contraintes of [SANS_CONTRAINTE, TOUTES]) {
const vide = salle({ tables: [[10, 4]], appartenances: [], tours: 2, contraintes });
assert.deepEqual(diagnostiquer(vide), attendu);
const tousExclus = salle({
tables: [[10, 4]],
appartenances: ['X', 'X', 'X'],
tours: 2,
reservations: [ancrer(1, 10), ancrer(2, 10)],
exclus: [1, 2, 3],
contraintes,
});
assert.deepEqual(diagnostiquer(tousExclus), attendu);
}
const vide = salle({ tables: [[10, 4]], appartenances: [], tours: 2 });
assert.deepEqual(diagnostiquer(vide), attendu);
const tousExclus = salle({
tables: [[10, 4]],
appartenances: ['X', 'X', 'X'],
tours: 2,
reservations: [ancrer(1, 10), ancrer(2, 10)],
exclus: [1, 2, 3],
});
assert.deepEqual(diagnostiquer(tousExclus), attendu);
});
test('le diagnostic ne modifie pas la configuration', () => {
@ -1109,310 +1041,3 @@ 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,12 +14,6 @@
// sans appartenance ne sont pas collègues. Un ensemble vide rend null, jamais
// 0 : le taux de diversité de qui ne rencontre aucun affilié, le minimum et la
// moyenne d'une population sans membre.
//
// 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 {
RESERVE,
SANS_GROUPE,
@ -47,15 +41,10 @@ import {
* @property {number[]} redondance r(p) = |F(p)| − A(p)
* @property {(number|null)[]} diversite d(p) = A(p) / |F(p)|, null si |F(p)| = 0
* @property {number[]} toursAssis
* @property {number[]} retoursChoisis par personne : ses occurrences
* parmi les revenus de parcourirTours
* @property {number[]} retoursImposes par personne : ses retours moins
* ses retours choisis
* @property {number[]} retoursChoisis par personne
* @property {number[]} retoursImposes par personne
* @property {TroisAgregats} aggRencontres
* @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
* redondance : la recherche et le
* classement le lisent
@ -64,8 +53,7 @@ import {
* @property {number} excedentCollisions cumulées − distinctes
* @property {{choisies: number, imposees: number}} rencontresRepetees
* paires réunies ≥ 2 fois ; « imposée » quand chaque rencontre de la
* paire a lieu à une table où les deux sont réservés ce tour-là ;
* choisies, les paires distinctes des revues de parcourirTours
* paire a lieu à une table où les deux sont réservés ce tour-là
* @property {number} maxRencontresPaire sur toutes les paires ; 0 si aucune
* @property {number} totalRetoursChoisis
* @property {number} totalRetoursImposes
@ -82,122 +70,57 @@ const UINT16_MAX = 0xffff;
// (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);
/**
* Le plan lu tour par tour, puis table par table, par index croissants : le
* seul code qui décide qu'une paire est réunie, de même appartenance ou
* revue, et qu'une personne revient. mesurer en tire chacun de ses chiffres,
* conflitsParTable (conflits.js) le détail par table.
*
* parTour[r][t], en index de personnes, chaque paire [a, b] avec a < b,
* chaque liste croissante — paires dans l'ordre de a, puis de b :
* - 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 = [];
// Rencontres de chaque paire, rangées par rangPaire, en parcourant chaque
// table de chaque tour. choisie vaut 1 pour une paire dont une rencontre au
// moins a lieu hors d'une table où les deux sont réservés ce tour-là. Une
// paire se réunit au plus une fois par tour : R borne chaque compte, qu'un
// Uint16Array tient tant que R ≤ 65 535.
function compterRencontres({ N, T, R, fixe }, tableDe) {
const paires = (N * (N - 1)) / 2;
const compte = R <= UINT16_MAX ? new Uint16Array(paires) : new Uint32Array(paires);
const choisie = new Uint8Array(paires);
for (let r = 0; r < R; r += 1) {
// Occupants de chaque table, par index croissants : a < b pour toute
// paire (liste[i], liste[j]) avec i < j. La boucle intérieure part du
// voisin suivant : une liste vide ou d'un seul occupant ne forme aucune
// paire, et nul n'est apparié à lui-même.
const assis = Array.from({ length: T }, () => []);
for (let p = 0; p < N; p += 1) {
const t = tableDe[p * R + r];
if (t !== RESERVE) assis[t].push(p);
}
const tables = [];
for (let t = 0; t < T; t += 1) {
const liste = assis[t];
const collegues = [];
const revues = [];
const revenus = [];
for (let i = 0; i < liste.length; i += 1) {
const a = liste[i];
if (revenu[a * R + r] === 1) revenus.push(a);
const aReserve = fixe[a * R + r] === t;
const g = groupe[a];
for (let j = i + 1; j < liste.length; j += 1) {
const b = liste[j];
const k = rangPaire(N, a, b);
parPaire[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]);
compte[k] += 1;
if (!aReserve || fixe[b * R + r] !== t) choisie[k] = 1;
}
}
tables.push({ collegues, revues, revenus });
}
parTour.push(tables);
}
return { parTour, parPaire, toursAssis, retours };
return { compte, choisie };
}
// Lecture du triangle des paires, dans l'ordre de rangPaire. Par personne :
// |met(p)|, |F(p)| et A(p), vu[p * G + g] notant que p a déjà croisé un
// affilié du groupe g. Sur toutes les paires : les paires réunies deux fois
// au moins, et le plus grand compte.
function lirePaires({ N, groupe, groupes }, parPaire) {
// affilié du groupe g. Par groupe : une paire de collègues réunie n fois
// porte n collisions cumulées et une paire distincte. Sur toutes les paires :
// les rencontres répétées, choisies ou imposées, et le plus grand compte.
function lirePaires({ N, groupe, groupes }, { compte, choisie }) {
const G = groupes.length;
const rencontres = new Int32Array(N);
const affilies = new Int32Array(N);
const vues = new Int32Array(N);
const vu = new Uint8Array(N * G);
let repetees = 0;
const collisions = new Array(G).fill(0);
const distinctes = new Array(G).fill(0);
let choisies = 0;
let imposees = 0;
let maxRencontresPaire = 0;
// p rencontre q : q compte dans met(p) ; un affilié compte aussi dans F(p),
@ -217,52 +140,70 @@ function lirePaires({ N, groupe, groupes }, parPaire) {
let k = 0;
for (let a = 0; a < N; a += 1) {
for (let b = a + 1; b < N; b += 1, k += 1) {
const n = parPaire[k];
const n = compte[k];
if (n === 0) continue;
rencontrer(a, b);
rencontrer(b, a);
if (n > maxRencontresPaire) maxRencontresPaire = n;
if (n >= 2) repetees += 1;
if (n >= 2) {
if (choisie[k] === 1) choisies += 1;
else imposees += 1;
}
const g = groupe[a];
if (g !== SANS_GROUPE && g === groupe[b]) {
collisions[g] += n;
distinctes[g] += 1;
}
}
}
return { rencontres, affilies, vues, repetees, maxRencontresPaire };
return {
rencontres,
affilies,
vues,
collisions,
distinctes,
rencontresRepetees: { choisies, imposees },
maxRencontresPaire,
};
}
// Lecture des listes de parcourirTours. Une paire de collègues réunie n fois
// porte n collisions cumulées et compte une fois parmi les paires
// distinctes de son groupe ; une paire revue compte une fois parmi les
// rencontres répétées choisies, à quelque tour qu'elle le soit ; chaque
// occurrence d'une personne revenue est un retour choisi.
function lireTours({ N, groupe, groupes }, parTour) {
const nombrePaires = (N * (N - 1)) / 2;
const collisions = new Array(groupes.length).fill(0);
const distinctes = new Array(groupes.length).fill(0);
const collegueVu = new Uint8Array(nombrePaires);
const revueVue = new Uint8Array(nombrePaires);
const retoursChoisis = new Array(N).fill(0);
let choisies = 0;
for (const tables of parTour) {
for (const { collegues, revues, revenus } of tables) {
for (const [a, b] of collegues) {
const g = groupe[a];
const k = rangPaire(N, a, b);
collisions[g] += 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;
// Tours assis et retours de chaque personne (§ 5.4). Pour une table visitée
// m fois, dont f à un tour où la personne y est réservée : max(0, f − 1)
// retours imposés et (m − 1) − imposés retours choisis. visites et reservees
// comptent par table les tours de la personne en cours ; le second parcours
// de ses tours solde chaque table à sa première occurrence et remet ses
// compteurs à zéro, si bien que la personne suivante les trouve nuls.
function compterRetours({ N, T, R, fixe }, tableDe) {
const toursAssis = [];
const retoursChoisis = [];
const retoursImposes = [];
const visites = new Int32Array(T);
const reservees = new Int32Array(T);
for (let p = 0; p < N; p += 1) {
let assis = 0;
for (let r = 0; r < R; r += 1) {
const t = tableDe[p * R + r];
if (t === RESERVE) continue;
assis += 1;
visites[t] += 1;
if (fixe[p * R + r] === t) reservees[t] += 1;
}
let choisis = 0;
let imposes = 0;
for (let r = 0; r < R; r += 1) {
const t = tableDe[p * R + r];
if (t === RESERVE || visites[t] === 0) continue;
const imposesTable = Math.max(0, reservees[t] - 1);
imposes += imposesTable;
choisis += visites[t] - 1 - imposesTable;
visites[t] = 0;
reservees[t] = 0;
}
toursAssis.push(assis);
retoursChoisis.push(choisis);
retoursImposes.push(imposes);
}
return { collisions, distinctes, choisies, retoursChoisis };
return { toursAssis, retoursChoisis, retoursImposes };
}
// Population sans membre. min reste null tant qu'aucune valeur n'y entre :
@ -283,14 +224,10 @@ const conclure = ({ min, somme, 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
// § 5.4, que replierParPopulation forme ; replier y range chaque valeur.
function troisAgregats(valeurs, statut, replier = ajouter) {
const { tous, mobiles, ancres } = replierParPopulation(valeurs, statut, VIDE, replier);
// § 5.4, que replierParPopulation forme.
function troisAgregats(valeurs, statut) {
const { tous, mobiles, ancres } = replierParPopulation(valeurs, statut, VIDE, ajouter);
return { tous: conclure(tous), mobiles: conclure(mobiles), ancres: conclure(ancres) };
}
@ -308,17 +245,16 @@ const somme = (valeurs) => valeurs.reduce((total, valeur) => total + valeur, 0);
* @returns {Mesures}
*/
export function mesurer(instance, tableDe) {
exigerPlanIndexe(instance, tableDe);
const { N, groupe, groupes, statut } = instance;
const { parTour, parPaire, toursAssis, retours } = parcourirTours(instance, tableDe);
const paires = lirePaires(instance, parPaire);
const { collisions, distinctes, choisies, retoursChoisis } = lireTours(instance, parTour);
const paires = lirePaires(instance, compterRencontres(instance, tableDe));
const { collisions, distinctes } = paires;
const rencontres = Array.from(paires.rencontres);
const affilies = Array.from(paires.affilies);
const appartenancesVues = Array.from(paires.vues);
const redondance = affilies.map((f, p) => f - appartenancesVues[p]);
const diversite = affilies.map((f, p) => (f === 0 ? null : appartenancesVues[p] / f));
const retoursImposes = retours.map((total, p) => total - retoursChoisis[p]);
const effectifs = new Array(groupes.length).fill(0);
for (let p = 0; p < N; p += 1) {
@ -334,6 +270,8 @@ export function mesurer(instance, tableDe) {
const collisionsCumulees = somme(collisions);
const pairesDistinctes = somme(distinctes);
const { toursAssis, retoursChoisis, retoursImposes } = compterRetours(instance, tableDe);
return {
rencontres,
affilies,
@ -345,13 +283,11 @@ export function mesurer(instance, tableDe) {
retoursImposes,
aggRencontres: troisAgregats(rencontres, statut),
aggRedondance: troisAgregats(redondance, statut),
aggAppartenancesVues: troisAgregats(appartenancesVues, statut),
aggDiversite: troisAgregats(diversite, statut, ajouterSiDefinie),
totalRedondance: somme(redondance),
collisionsCumulees,
pairesDistinctes,
excedentCollisions: collisionsCumulees - pairesDistinctes,
rencontresRepetees: { choisies, imposees: paires.repetees - choisies },
rencontresRepetees: paires.rencontresRepetees,
maxRencontresPaire: paires.maxRencontresPaire,
totalRetoursChoisis: somme(retoursChoisis),
totalRetoursImposes: somme(retoursImposes),

View file

@ -9,7 +9,7 @@
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { indexerPlan, normaliser } from './configuration.js';
import { mesurer, parcourirTours } from './indicateurs.js';
import { mesurer } from './indicateurs.js';
const SANS_CONTRAINTE = {
separerAppartenances: false,
@ -135,18 +135,6 @@ describe('mesurer', () => {
mobiles: { min: 6, moyenne: 6, effectif: 12 },
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,
collisionsCumulees: 0,
pairesDistinctes: 0,
@ -280,46 +268,6 @@ describe('mesurer', () => {
mobiles: { min: 0, moyenne: 0, effectif: 3 },
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", () => {
@ -573,8 +521,6 @@ describe('mesurer', () => {
retoursImposes: [],
aggRencontres: { 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,
collisionsCumulees: 0,
pairesDistinctes: 0,
@ -631,84 +577,3 @@ 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,9 +24,7 @@
// 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
// personne prend le rang de l'autre, ce que garde l'ordre des sièges quand
// 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
// ils sont attribués. Sans attribution, chaque liste se trie par
// 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
// entière dès que ses poses rang par rang pèsent plus que cette pose, et
@ -66,15 +64,12 @@ import { ErreurStockage } from './erreurs.js';
* @typedef {OperationPoser|OperationRetirer} Operation
*/
// Règles de la charge, du retenu, d'un identifiant et de l'occupant d'une
// liste de table — un identifiant, ou null pour une chaise vide — dans le
// schéma du fichier d'état.
// Règles de la charge, du retenu et d'un identifiant dans le schéma du
// fichier d'état.
const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
const CHARGE = regleDuChamp(SCHEMA, 'charge');
const RETENU = regleDuChamp(CHARGE, 'retenu');
const PROPOSITION = regleDuChamp(CHARGE, 'propositions').element;
const IDENTIFIANT = regleDuChamp(PROPOSITION, 'id');
const OCCUPANT = regleDuChamp(regleDuChamp(PROPOSITION, 'placement').element, 'sieges').element.element;
const IDENTIFIANT = regleDuChamp(regleDuChamp(CHARGE, 'propositions').element, 'id');
// Vrai pour un objet qui n'est ni null ni une liste.
const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
@ -108,9 +103,9 @@ const poids = (operations) => JSON.stringify(operations).length;
// objets se comparent champ par champ, dans l'ordre du schéma. Deux listes
// d'enregistrements à identifiants distincts se comparent par identifiant ;
// 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 ou
// d'occupants exceptées, qui posent b entière quand leurs poses rang par
// rang pèsent plus que la sienne.
// longueur se comparent rang par rang — deux listes d'identifiants
// exceptées, qui posent b entière quand leurs poses rang par rang pèsent
// plus que la sienne.
function comparer(a, b, regle, chemin, operations) {
if (a === null || b === null || (regle.genre !== 'objet' && regle.genre !== 'liste')) {
if (a !== b) operations.push({ op: 'poser', chemin, valeur: b });
@ -122,25 +117,23 @@ function comparer(a, b, regle, chemin, operations) {
comparerParIdentifiant(a, b, regle.element, chemin, operations);
} else if (a.length !== b.length) {
operations.push({ op: 'poser', chemin, valeur: b });
} else if (regle.element === IDENTIFIANT || regle.element === OCCUPANT) {
comparerIdentifiants(a, b, regle.element, chemin, operations);
} else if (regle.element === IDENTIFIANT) {
comparerIdentifiants(a, b, chemin, operations);
} else {
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
// même longueur, d'élément de règle regleElement, au bout de chemin : une
// pose par rang qui change, ou b entière quand ces poses pèsent plus que la
// sienne. Une liste triée par identifiant — une liste de table sans
// attribution, une réserve, les participants d'un plan — décale d'un rang
// chaque entrée qu'un identifiant remplacé franchit, et ses poses rang par
// rang se multiplient ; ce qui s'en écrit ne pèse jamais plus que la liste
// 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) {
// même longueur, au bout de chemin : une pose par rang qui change, ou b
// entière quand ces poses pèsent plus que la sienne. Une liste triée par
// identifiant — une liste de table sans attribution, une réserve, les
// participants d'un plan — décale d'un rang chaque entrée qu'un identifiant
// remplacé franchit, et ses poses rang par rang se multiplient ; ce qui
// s'en écrit ne pèse jamais plus que la liste posée entière.
function comparerIdentifiants(a, b, chemin, operations) {
const parRang = [];
a.forEach((id, rang) => comparer(id, b[rang], regleElement, [...chemin, rang], parRang));
a.forEach((id, rang) => comparer(id, b[rang], IDENTIFIANT, [...chemin, rang], parRang));
const entiere = { op: 'poser', chemin, valeur: b };
operations.push(...(poids(parRang) > poids([entiere]) ? [entiere] : parRang));
}

View file

@ -1662,90 +1662,3 @@ 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,12 +82,6 @@ const NOMBRE = regle('nombre');
// reste ainsi loin de 2^53, où l'analyse ne le relirait plus. Au-delà, la
// valeur sort de sa règle.
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 });
// Ordres des listes de la charge (§ 8.8, § 8.9). canonique.js range les
@ -202,18 +196,15 @@ const TITRE = objet([
]);
// Le placement d'une proposition ou du retenu (§ 8.9) : un tour par élément,
// chacun sur sa ligne. sieges porte une liste d'occupants par table
// chacun sur sa ligne. sieges porte une liste d'identifiants par table
// 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
// 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
// identifiant croissant ; vrai, il est celui des sièges et se garde. reserve
// porte ceux qui ne sont assis nulle part à ce tour, un ensemble : elle se
// trie par identifiant croissant, quel que soit le drapeau.
const PLACEMENT = liste(
objet([
['sieges', liste(liste(OCCUPANT, { triSansAttribution: croissant }))],
['sieges', liste(liste(IDENTIFIANT, { triSansAttribution: croissant }))],
['reserve', liste(IDENTIFIANT, { tri: croissant })],
]),
{ mise: 'lignes' },

View file

@ -1026,48 +1026,3 @@ describe('premiereFaute : le parcours de forme, sans lever (§ 8.8, § 8.9)', ()
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,10 +33,9 @@
//
// 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
// TypeMismatchError pour une entrée d'un autre genre que celui demandé, hors
// le cas d'un nom non converti ci-dessous — lève ABSENT ; toute autre
// exception d'une lecture lève LECTURE, car une panne n'est pas une
// absence ; tout échec d'une écriture lève ECRITURE. Les
// TypeMismatchError pour une entrée d'un autre genre que celui demandé —
// lève ABSENT ; toute autre 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
// l'exception : SecurityError pour un OPFS que le navigateur refuse à
// l'origine, NotReadableError pour un fichier qui change pendant qu'on le
@ -44,17 +43,6 @@
// TypeMismatchError, QuotaExceededError… Le chemin '' désigne la racine
// 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
// choisirDossier lèvent NON_DISPONIBLE {primitive}. choisirFichierAImporter
// passe par un champ de fichier, que le navigateur n'ouvre que dans le
@ -81,10 +69,6 @@ const TYPE_DIFFERENT = 'TypeMismatchError';
// Cause d'une sonde dont le témoin relu diffère du texte écrit.
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
// 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.
@ -95,9 +79,7 @@ const DECODEUR = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
const ignorer = () => {};
// Vrai pour l'exception de l'OPFS qui dit qu'une entrée manque : absente, ou
// 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.
// d'un autre genre que celui demandé.
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
@ -152,50 +134,17 @@ 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 ;
// 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
// dossier du produit absent lève NotFoundError (§ 8.6). Lève l'exception de
// l'OPFS : NotFoundError pour un dossier absent, TypeMismatchError pour un
// fichier à sa place ; NOM_NON_CONVERTI pour un dossier résolu sur celui qui
// le porte.
// fichier à sa place.
async function dossierDe(segments, creer = false) {
const options = { create: creer };
const produit = { create: creer && segments.length === 0 };
let dossier = await sousDossier(await navigator.storage.getDirectory(), DOSSIER_PRODUIT, produit);
for (const segment of segments) dossier = await sousDossier(dossier, segment, options);
let dossier = await (await navigator.storage.getDirectory()).getDirectoryHandle(DOSSIER_PRODUIT, produit);
for (const segment of segments) dossier = await dossier.getDirectoryHandle(segment, options);
return dossier;
}
@ -210,10 +159,10 @@ async function dossierExistant(segments) {
}
// Genre de l'entrée nom du dossier : 'fichier', 'dossier', ou null quand elle
// n'existe pas. Un nom non converti lève NOM_NON_CONVERTI.
// n'existe pas.
async function genreDe(dossier, nom) {
try {
await fichierDe(dossier, nom);
await dossier.getFileHandle(nom);
return 'fichier';
} catch (erreur) {
if (erreur?.name === TYPE_DIFFERENT) return 'dossier';
@ -251,7 +200,7 @@ async function ecrireParFlux(dossier, nom, garder, remplir) {
let poignee;
let cree = false;
try {
poignee = await fichierDe(dossier, nom);
poignee = await dossier.getFileHandle(nom);
} catch (erreur) {
if (erreur?.name !== 'NotFoundError') throw erreur;
poignee = await dossier.getFileHandle(nom, { create: true });
@ -286,8 +235,7 @@ async function decrire(poignee) {
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
// elle n'existe pas ; ECRITURE TypeMismatchError quand c'est un dossier ;
// NOM_NON_CONVERTI, que genreDe lève, avant toute lecture de ses octets.
// elle n'existe pas ; ECRITURE TypeMismatchError quand c'est un dossier.
async function lireSource(loc) {
if (loc.nom === null) throw refus(loc, TYPE_DIFFERENT);
const dossier = await dossierExistant(loc.parent);
@ -390,7 +338,7 @@ export function creerFichiersWeb() {
if (loc.nom === null) throw absent(chemin);
let lu;
try {
lu = await lire(await fichierDe(await dossierDe(loc.parent), loc.nom));
lu = await lire(await (await dossierDe(loc.parent)).getFileHandle(loc.nom));
} catch (erreur) {
throw echecLecture(loc, erreur);
}

View file

@ -124,27 +124,6 @@ describe('fichiers web : la plateforme et sa racine (§ 8.8, § 13.1)', () => {
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 () => {
const fs = await preparer();
await fs.creerDossier(DOCUMENTS, 'corbeille');
@ -246,92 +225,6 @@ describe("fichiers web : ce que l'OPFS refuse", () => {
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,
// l'écriture, la fermeture qui porte le contenu sur la cible.
const PANNES = [

View file

@ -23,18 +23,14 @@
// 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
// parcours même de l'analyse, premiereFaute, qui nomme les chemins à sa
// manière. Une liste de table porte null à la place d'une chaise vide
// (§ 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.
// manière. 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 { ErreurStockage } from './erreurs.js';
/**
* Une faute d'un placement ; types.js en donne la table des codes. FORME :
* 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
* une valeur sort de sa règle, ou la déclaration se contredit ; chemin
* 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
* contredit celle que le placement déclare ; tour et table null désignent le
@ -70,9 +66,8 @@ import { ErreurStockage } from './erreurs.js';
* @property {number|null} numero numéro affiché de la table dans la
* charge ; null pour la réserve et pour
* une table que la charge n'a plus
* @property {number|null} siege rang dans la liste, à partir de 1,
* chaises vides comprises, quand les
* sièges sont attribués ; null sinon
* @property {number|null} siege rang dans la liste, à partir de 1, quand
* les sièges sont attribués ; null sinon
* @property {number} participant
* @property {string|null} nom null pour une personne que la charge
* n'a plus
@ -98,9 +93,8 @@ import { ErreurStockage } from './erreurs.js';
const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
const CHARGE = regleDuChamp(SCHEMA, 'charge');
// Règles d'une proposition et du retenu. Aucune des valeurs qu'elles
// contiennent n'admet null, hors de l'occupant d'une liste de table, la
// chaise vide ; examiner traite le retenu nul, son absence, avant de lire
// le retenu.
// contiennent n'admet null ; examiner traite le retenu nul, son absence,
// avant de lire le retenu.
const PROPOSITION = regleDuChamp(CHARGE, 'propositions').element;
const RETENU = regleDuChamp(CHARGE, 'retenu');
const IDENTIFIANT = regleDuChamp(PROPOSITION, 'id');
@ -133,32 +127,14 @@ 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
// (§ 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
// 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é, chaises vides
// comprises ; le multiensemble du tour, listes de table et réserve
// comprises, où chaque identifiant déclaré figure exactement une fois —
// doublons, inconnus, puis manquants, chacun nommé une fois par tour, par
// identifiant croissant. Une chaise vide n'y entre pas.
// déclarée, dans l'ordre déclaré, contre sa capacité ; le multiensemble du
// tour, listes de table et réserve comprises, où chaque identifiant déclaré
// figure exactement une fois — doublons, inconnus, puis manquants, chacun
// nommé une fois par tour, par identifiant croissant.
//
// 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
@ -191,7 +167,6 @@ function fautesDeCoherence({ tables, capacites, tours, participants, placement }
const inconnus = [];
for (const ids of [...sieges, reserve]) {
for (const id of ids) {
if (id === null) continue;
const deja = occurrences.get(id) ?? 0;
occurrences.set(id, deja + 1);
if (deja === 0 && !estDeclare.has(id)) inconnus.push(id);
@ -208,11 +183,11 @@ function fautesDeCoherence({ tables, capacites, tours, participants, placement }
return liste;
}
// Fautes de valeur sous regle : la première faute de forme, de
// déclaration ou de chaise vide seule, qui rend la cohérence illisible ;
// sinon les fautes de cohérence.
// Fautes de valeur sous regle : la première faute de forme ou de
// déclaration seule, qui rend la cohérence illisible ; sinon les fautes de
// cohérence.
function fautesSelon(valeur, regle) {
const chemin = premiereFaute(valeur, regle) ?? fauteDeDeclaration(valeur) ?? fauteDeChaiseVide(valeur);
const chemin = premiereFaute(valeur, regle) ?? fauteDeDeclaration(valeur);
return chemin === null ? fautesDeCoherence(valeur) : [{ code: 'FORME', chemin }];
}
@ -222,9 +197,7 @@ function fautesSelon(valeur, regle) {
* 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
* 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,
* déclaration : la première faute de l'une ou de l'autre est seule rendue,
* { code: 'FORME', chemin }. Sinon, dans l'ordre des tours puis des tables :
* { code: 'LONGUEUR', tour, table, declare, ecrit } — tour null pour le
* nombre de tours, rendu en premier ; table null pour le nombre de listes
@ -275,9 +248,8 @@ function exigerCoherent(p, fonction) {
* configuration, lue par identifiant ; le nombre de tours ; les participants
* 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
* identifiant croissant ; avec, elle garde son ordre, celui des sièges, et
* ses chaises vides, null. La réserve, un ensemble, se trie dans les deux
* cas. Les clés suivent l'ordre
* identifiant croissant ; avec, elle garde son ordre, celui des sièges. 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
* modifiés.
*
@ -291,8 +263,7 @@ function exigerCoherent(p, fonction) {
* 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
* domaine, plan que la configuration contredit — table inconnue d'elle,
* nombre de tours, personne non placée, liste au-delà d'une capacité,
* chaise vide sans attribution ou en fin de liste.
* nombre de tours, personne non placée, liste au-delà d'une capacité.
*
* @param {import('../moteur/recherche.js').Proposition} proposition
* @param {import('../moteur/types.js').Configuration} configuration
@ -338,9 +309,9 @@ export function versFichier(proposition, configuration, { produitVersion, attrib
/**
* Plan du moteur d'un placement de fichier, proposition ou retenu : les
* tables dans l'ordre déclaré, chaque liste de table sans ses chaises
* vides, et chaque réserve, par identifiant croissant, comme le moteur les
* rend ; l'ordre des sièges n'y entre pas. Aucune liste n'est partagée avec p. Lève TypeError quand p
* tables dans l'ordre déclaré, chaque liste de table et chaque réserve par
* identifiant croissant, comme le moteur les 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).
*
* @param {import('./types.js').PropositionFichier|import('./types.js').Retenu} p
@ -350,7 +321,7 @@ export function planDepuisFichier(p) {
exigerCoherent(p, 'planDepuisFichier');
return {
tables: [...p.tables],
tours: p.placement.map(({ sieges }) => sieges.map((liste) => liste.filter((id) => id !== null).sort(croissant))),
tours: p.placement.map(({ sieges }) => sieges.map((liste) => [...liste].sort(croissant))),
reserves: p.placement.map(({ reserve }) => [...reserve].sort(croissant)),
};
}
@ -440,8 +411,7 @@ const ordreDesTables = (a, b) =>
* 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.
* siege est le rang à partir de 1 quand le placement attribue les sièges,
* 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
* null sinon et dans la réserve. 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.
*
* Un placement incohérent (fautes : LONGUEUR, DOUBLON, INCONNU, MANQUANT)
@ -484,7 +454,6 @@ export function formeNommee(p, charge) {
// au-delà des tables déclarées.
const ajouterListe = (tour, ids, table, numero) => {
ids.forEach((participant, position) => {
if (participant === null) return;
ajouter(tour, table, numero, p.siegesAttribues ? position + 1 : null, participant, false);
});
};

View file

@ -1495,140 +1495,3 @@ describe('déterminisme (§ 8.8, § 14.7)', () => {
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)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Gardes de l'arborescence, lues dans le texte des sources : le moteur, la
// géométrie du plan, le générateur de démonstrations, le stockage et
// l'analyseur CSV n'appellent aucune source non reproductible (§ 14.7), et ne parcourent les clés d'un
// Gardes de l'arborescence, lues dans le texte des sources : le moteur, le
// générateur de démonstrations, le stockage et 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
// 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
@ -138,10 +138,10 @@ const PARCOURS_DE_CLES_ADMIS = [
];
// Arbres dont les modules ne lisent aucune source non reproductible (§ 14.7) :
// le moteur, la géométrie du plan et le générateur de démonstrations, puis le
// stockage et l'analyseur CSV, qui reçoivent l'horloge et l'aléa de
// l'application, en paramètre.
const ARBRES_DETERMINISTES = ['src/moteur', 'src/geometrie', 'src/demo', 'src/stockage', 'src/csv'];
// le moteur et le générateur de démonstrations, puis le stockage et
// l'analyseur CSV, qui reçoivent l'horloge et l'aléa de l'application, en
// paramètre.
const ARBRES_DETERMINISTES = ['src/moteur', 'src/demo', 'src/stockage', 'src/csv'];
// 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.
@ -196,7 +196,6 @@ function releverDeterminisme(racine, admis = PARCOURS_DE_CLES_ADMIS) {
// pour éprouver le refus d'un balayage vide.
const MODULES_VIDES = Object.freeze({
'src/moteur/a.js': '',
'src/geometrie/a.js': '',
'src/demo/a.js': '',
'src/stockage/a.js': '',
'src/csv/a.js': '',
@ -211,7 +210,7 @@ const sansArbre = (fichiers, arbre) =>
const releverSansException = (racine) => releverDeterminisme(racine, []);
describe('arborescence : déterminisme (§ 14.7)', () => {
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", () => {
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", () => {
assert.deepEqual(releverDeterminisme(RACINE), []);
});
@ -316,7 +315,7 @@ describe('arborescence : déterminisme (§ 14.7)', () => {
'src/demo/tirage.cjs:1 Date.now',
'src/demo/tirage.mjs:1 Math.random',
]);
for (const arbre of ['src/moteur', 'src/geometrie', 'src/demo', 'src/stockage', 'src/csv']) {
for (const arbre of ['src/moteur', 'src/demo', 'src/stockage', 'src/csv']) {
const sansModule = { ...sansArbre(MODULES_VIDES, arbre), [`${arbre}/a.test.js`]: '' };
assert.throws(
() => avecArbre(sansModule, releverSansException),
@ -326,24 +325,6 @@ 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', () => {
const fichiers = {
...MODULES_VIDES,
@ -489,9 +470,7 @@ const PAQUETS_DE_PLATEFORME = /^(?:svelte|electron|@capacitor\/.+)$/;
// l'application.
//
// Le moteur et la géométrie ne connaissent ni l'écran, ni les fichiers, ni ce
// 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
// qui les commande. Le stockage lit et écrit par le système de fichiers qu'il
// 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.
// L'analyseur CSV lit le modèle du stockage. L'application câble le moteur,
@ -499,19 +478,12 @@ const PAQUETS_DE_PLATEFORME = /^(?:svelte|electron|@capacitor\/.+)$/;
// l'interface.
const COUCHES = [
{
arbres: ['src/moteur'],
arbres: ['src/moteur', 'src/geometrie'],
requis: ['src/moteur'],
exterieures: ['interface', 'application', 'stockage'],
paquets: PAQUETS_DE_PLATEFORME,
navigateur: [],
},
{
arbres: ['src/geometrie'],
requis: ['src/geometrie'],
exterieures: ['interface', 'application', 'stockage', 'moteur', 'csv', 'demo'],
paquets: PAQUETS_DE_PLATEFORME,
navigateur: [],
},
{
arbres: ['src/stockage'],
requis: ['src/stockage'],
@ -843,10 +815,10 @@ describe('arborescence : frontière des couches (§ 13.4)', () => {
'src/moteur/a.js:26 import ../application/seance.js',
'src/moteur/b.js:4 import ../interface/App.svelte',
'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:3 import ../application/commandes.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/stockage/fichiers.js:1 import @capacitor/core',
]);
@ -1017,57 +989,10 @@ describe('arborescence : frontière des couches (§ 13.4)', () => {
]);
});
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', () => {
test('src/geometrie et src/application peuvent manquer ; un src/moteur, un src/stockage ou un src/csv sans module fait échouer la garde', () => {
assert.deepEqual(avecArbre(MODULES_VIDES, releverFrontiere), []);
for (const arbre of ['src/moteur', 'src/geometrie', 'src/stockage', 'src/csv']) {
const sansModule = { ...sansArbre(MODULES_VIDES, arbre), [`${arbre}/a.test.js`]: '' };
for (const arbre of ['src/moteur', 'src/stockage', 'src/csv']) {
const sansModule = { ...sansArbre(MODULES_VIDES, arbre), [`${arbre}/a.test.js`]: '', 'src/geometrie/echelle.js': '' };
assert.throws(
() => avecArbre(sansModule, releverFrontiere),
new RegExp(`${arbre} ne fournit aucun module`),

View file

@ -6,41 +6,7 @@
// 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
// met celui de nixpkgs.
//
// 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, 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 } : {};
const chemin = env.GTT_CHROMIUM;
return chemin ? { launchOptions: { executablePath: chemin } } : {};
}

View file

@ -2,25 +2,20 @@
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le Chromium des épreuves du navigateur : celui de Playwright par défaut,
// 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.
// celui que GTT_CHROMIUM désigne sinon, et vitest.config.js applique la règle.
import assert from 'node:assert/strict';
import configuration from '../vitest.config.js';
import { optionsChromium } from './chromium.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', () => {
test('sans GTT_CHROMIUM, Playwright lance le Chromium qu’il a téléchargé', () => {
assert.deepEqual(optionsChromium(UTF8), {});
assert.deepEqual(optionsChromium({ ...UTF8, GTT_CHROMIUM: '' }), {});
assert.deepEqual(optionsChromium({}), {});
assert.deepEqual(optionsChromium({ GTT_CHROMIUM: '' }), {});
});
test('GTT_CHROMIUM désigne l’exécutable que Playwright lance', () => {
assert.deepEqual(optionsChromium({ ...UTF8, GTT_CHROMIUM: '/nix/store/abc-chromium/bin/chromium' }), {
assert.deepEqual(optionsChromium({ GTT_CHROMIUM: '/nix/store/abc-chromium/bin/chromium' }), {
launchOptions: { executablePath: '/nix/store/abc-chromium/bin/chromium' },
});
});
@ -30,44 +25,3 @@ describe('chromium : l’exécutable des épreuves du navigateur', () => {
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,23 +57,18 @@ function dossierTemporaire() {
}
// test du lanceur, dont chaque épreuve retire à sa fin les dossiers
// temporaires qu'elle a ouverts ; le corps reçoit le contexte de l'épreuve.
// temporaires qu'elle a ouverts.
function testNettoye(nom, ...suite) {
const corps = suite.pop();
test(nom, ...suite, async (contexte) => {
test(nom, ...suite, async () => {
try {
await corps(contexte);
await corps();
} finally {
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
// 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
@ -355,14 +350,7 @@ describe('coquille sur un vrai disque', () => {
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",
{ skip: SOUS_WINDOWS },
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;
}
async () => {
const documents = join(dossierTemporaire(), 'Documents');
mkdirSync(documents, { recursive: true });
const fichiers = creerFichiers({
@ -378,7 +366,7 @@ describe('coquille sur un vrai disque', () => {
env: { ...process.env, LC_ALL: 'C', TZ: 'UTC' },
encoding: 'utf8',
});
assert.equal(enUtc.status, 0, ditPar(enUtc));
assert.equal(enUtc.status, 0, enUtc.stderr);
assert.equal(pose.demarrage, enUtc.stdout.trim());
// Un second processus, dans un fuseau qui n'est pas celui de
@ -399,7 +387,7 @@ describe('coquille sur un vrai disque', () => {
},
encoding: 'utf8',
});
assert.equal(relu.status, 0, ditPar(relu));
assert.equal(relu.status, 0, relu.stderr);
assert.deepEqual(JSON.parse(relu.stdout), {
fuseau: autre,
tenu: { pris: false, seance: 'seance-a', depuis: pose.depuis, vivant: true },