Compare commits
8 commits
8d03d5ba41
...
82e57f47df
| Author | SHA1 | Date | |
|---|---|---|---|
| 82e57f47df | |||
| 71076afe4d | |||
| 63b997faa9 | |||
| eec14bdcb0 | |||
| 1ea572468d | |||
| 7667134bd7 | |||
| 03c04bdf16 | |||
| 63efdb21a1 |
80 changed files with 7406 additions and 989 deletions
|
|
@ -17,6 +17,12 @@
|
||||||
# que celle de l'hôte, NixOS lui-même (l'image nix éprouve shell.nix, sur
|
# que celle de l'hôte, NixOS lui-même (l'image nix éprouve shell.nix, sur
|
||||||
# une base qui n'est pas NixOS), une vraie session graphique.
|
# une base qui n'est pas NixOS), une vraie session graphique.
|
||||||
#
|
#
|
||||||
|
# Les étapes tournent sous LANG=C.UTF-8, des paramètres régionaux UTF-8 que
|
||||||
|
# la plupart des images ne posent pas, et sous lesquels Chromium nomme une
|
||||||
|
# entrée hors ASCII de l'OPFS. La préparation installe ps (procps) là où
|
||||||
|
# l'image ne le porte pas : une épreuve de la coquille lit par lui l'heure de
|
||||||
|
# démarrage d'un processus.
|
||||||
|
#
|
||||||
# GTT_PARALLELES (4 par défaut) borne les conteneurs simultanés ; les
|
# GTT_PARALLELES (4 par défaut) borne les conteneurs simultanés ; les
|
||||||
# journaux vont dans GTT_JOURNAUX, un dossier temporaire par défaut. Les
|
# journaux vont dans GTT_JOURNAUX, un dossier temporaire par défaut. Les
|
||||||
# téléchargements — npm, Electron, Chromium, Node — se partagent entre
|
# téléchargements — npm, Electron, Chromium, Node — se partagent entre
|
||||||
|
|
@ -103,24 +109,32 @@ rm -rf "$source_copie"
|
||||||
mkdir -p "$source_copie"
|
mkdir -p "$source_copie"
|
||||||
(cd "$racine" && git ls-files -co --exclude-standard -z | tar --null -cf - -T - | tar -xf - -C "$source_copie")
|
(cd "$racine" && git ls-files -co --exclude-standard -z | tar --null -cf - -T - | tar -xf - -C "$source_copie")
|
||||||
|
|
||||||
# Préparation sous root, par famille : sudo et un compte ordinaire « dev »
|
# Préparation sous root, par famille : sudo, ps quand l'image ne le porte
|
||||||
# qui l'emploie sans mot de passe, comme un poste de développement. Son uid
|
# pas — procps-ng chez Fedora, AlmaLinux, Rocky et Arch, procps ailleurs —,
|
||||||
# est le même dans chaque conteneur — 1500, libre aussi sous Ubuntu, dont
|
# et un compte ordinaire « dev » qui emploie sudo sans mot de passe, comme un
|
||||||
# l'image donne 1000 à son propre compte — : les caches partagés ne
|
# poste de développement. Son uid est le même dans chaque conteneur — 1500,
|
||||||
# changent jamais de propriétaire.
|
# libre aussi sous Ubuntu, dont l'image donne 1000 à son propre compte — :
|
||||||
|
# les caches partagés ne changent jamais de propriétaire.
|
||||||
preparation() {
|
preparation() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
ubuntu-* | debian-*) echo 'apt-get update -q && DEBIAN_FRONTEND=noninteractive apt-get install -y -q sudo' ;;
|
ubuntu-* | debian-*) echo 'apt-get update -q && DEBIAN_FRONTEND=noninteractive apt-get install -y -q sudo $(command -v ps > /dev/null || echo procps)' ;;
|
||||||
fedora-* | almalinux-* | rocky-*) echo 'dnf install -y sudo shadow-utils' ;;
|
fedora-* | almalinux-* | rocky-*) echo 'dnf install -y sudo shadow-utils $(command -v ps > /dev/null || echo procps-ng)' ;;
|
||||||
opensuse-*) echo 'zypper --non-interactive install sudo shadow' ;;
|
opensuse-*) echo 'zypper --non-interactive install sudo shadow $(command -v ps > /dev/null || echo procps)' ;;
|
||||||
arch) echo 'pacman -Syu --noconfirm sudo' ;;
|
arch) echo 'pacman -Syu --noconfirm sudo $(command -v ps > /dev/null || echo procps-ng)' ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
# Étapes sous le compte dev, chacune annoncée par une ligne « === étape
|
# Étapes sous le compte dev, chacune annoncée par une ligne « === étape
|
||||||
# statut » que le bilan relit. L'essai et le serveur de Vite ne se jouent
|
# statut » que le bilan relit. L'essai et le serveur de Vite ne se jouent
|
||||||
# que sur une installation réussie.
|
# que sur une installation réussie. Elles tournent sous LANG=C.UTF-8, des
|
||||||
|
# paramètres régionaux UTF-8 comme ceux d'un poste de développement, que la
|
||||||
|
# plupart des images ne posent pas : sous le « C » qu'elles laissent,
|
||||||
|
# Chromium ne convertit plus un nom hors ASCII de l'OPFS, et les épreuves
|
||||||
|
# du navigateur n'éprouvent plus le projet. C.UTF-8 vient avec la glibc,
|
||||||
|
# sans paquet de langue ; une image qui ne le porterait pas retomberait sur
|
||||||
|
# « C », comme sans la variable.
|
||||||
ETAPES_DEV='
|
ETAPES_DEV='
|
||||||
|
export LANG=C.UTF-8
|
||||||
etape() { local nom="$1"; shift; if "$@"; then echo "=== $nom ok"; else echo "=== $nom ÉCHEC"; return 1; fi; }
|
etape() { local nom="$1"; shift; if "$@"; then echo "=== $nom ok"; else echo "=== $nom ÉCHEC"; return 1; fi; }
|
||||||
cd "$HOME/projet"
|
cd "$HOME/projet"
|
||||||
etape install_dev ./install_dev.sh || exit 1
|
etape install_dev ./install_dev.sh || exit 1
|
||||||
|
|
|
||||||
7
spec.md
7
spec.md
|
|
@ -1565,7 +1565,12 @@ régénérable.
|
||||||
Puis, **par tour** : la liste des tables, chacune étant la liste des
|
Puis, **par tour** : la liste des tables, chacune étant la liste des
|
||||||
**identifiants entiers** de ses occupants **dans l'ordre des sièges** ; et la
|
**identifiants entiers** de ses occupants **dans l'ordre des sièges** ; et la
|
||||||
**réserve**, la liste des identifiants assis nulle part à ce tour. Le numéro de
|
**réserve**, la liste des identifiants assis nulle part à ce tour. Le numéro de
|
||||||
siège n'est pas stocké : il est la position dans la liste.
|
siège n'est pas stocké : il est la position dans la liste. Quand les sièges sont
|
||||||
|
attribués, une chaise vide avant le dernier occupant, siège 1 compris, s'écrit
|
||||||
|
`null` à sa position, et une liste ne finit jamais par `null` : retirer une
|
||||||
|
personne libère sa chaise, et aucun autre siège ne change — le placement se
|
||||||
|
prépare, il ne suit pas la salle.
|
||||||
|
Sans attribution, aucune liste ne porte `null`.
|
||||||
|
|
||||||
**Quand les sièges ne sont pas attribués** (§ 5.3), l'ordre à l'intérieur d'une
|
**Quand les sièges ne sont pas attribués** (§ 5.3), l'ordre à l'intérieur d'une
|
||||||
table ne porte aucune information : le logiciel l'écrit **trié par identifiant
|
table ne porte aucune information : le logiciel l'écrit **trié par identifiant
|
||||||
|
|
|
||||||
|
|
@ -30,28 +30,15 @@ import { serialiserCharge } from '../stockage/canonique.js';
|
||||||
import { SCHEMA, clesRangees, creerCharge, etatDeduit, premiereFaute } from '../stockage/document.js';
|
import { SCHEMA, clesRangees, creerCharge, etatDeduit, premiereFaute } from '../stockage/document.js';
|
||||||
import { ErreurStockage } from '../stockage/erreurs.js';
|
import { ErreurStockage } from '../stockage/erreurs.js';
|
||||||
import { fautes, formeNommee, versFichier } from '../stockage/placements.js';
|
import { fautes, formeNommee, versFichier } from '../stockage/placements.js';
|
||||||
|
import { COMMANDES_MOBILIER } from './commandes_mobilier.js';
|
||||||
|
import { COMMANDES_PLACES } from './commandes_places.js';
|
||||||
|
import { ErreurCommande, refus } from './erreurs.js';
|
||||||
import { libelle } from './libelles.js';
|
import { libelle } from './libelles.js';
|
||||||
|
import { nomAffiche } from './personnes.js';
|
||||||
|
|
||||||
/**
|
// Le refus d'une commande vit dans erreurs.js, le nom affiché d'une personne
|
||||||
* Le refus d'une commande : son code, et des détails que l'appelant lit pour
|
// dans personnes.js : les importateurs de ce module les y trouvent encore.
|
||||||
* agir, remede compris. Le message, code et détails en JSON, sert aux traces ;
|
export { ErreurCommande, nomAffiche };
|
||||||
* le texte affiché vient de la table des libellés (§ 14.6).
|
|
||||||
*/
|
|
||||||
export class ErreurCommande extends Error {
|
|
||||||
/**
|
|
||||||
* @param {string} code
|
|
||||||
* @param {Object} [details]
|
|
||||||
*/
|
|
||||||
constructor(code, details = {}) {
|
|
||||||
super(`${code} ${JSON.stringify(details)}`);
|
|
||||||
this.code = code;
|
|
||||||
this.details = details;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ErreurCommande.prototype.name = 'ErreurCommande';
|
|
||||||
|
|
||||||
// Le refus de code donné : ses détails, puis le remède, null sans lui.
|
|
||||||
const refus = (code, details = {}, remede = null) => new ErreurCommande(code, { ...details, remede });
|
|
||||||
|
|
||||||
// Un avertissement : il n'empêche rien.
|
// Un avertissement : il n'empêche rien.
|
||||||
const avertissement = (code, details) => ({ code, details });
|
const avertissement = (code, details) => ({ code, details });
|
||||||
|
|
@ -120,9 +107,6 @@ function participantDe(charge, id) {
|
||||||
return participant;
|
return participant;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Le nom d'une personne tel que les libellés l'écrivent : prénom, puis nom.
|
|
||||||
const personneDe = ({ nom, prenom }) => (prenom === null ? nom : `${prenom} ${nom}`);
|
|
||||||
|
|
||||||
// Un plan bloqué refuse toute modification ; débloquer seul le lève (§ 9).
|
// Un plan bloqué refuse toute modification ; débloquer seul le lève (§ 9).
|
||||||
function exigerModifiable(charge) {
|
function exigerModifiable(charge) {
|
||||||
if (charge.evenement.etat === 'bloque') throw refus('PLAN_BLOQUE', {}, { geste: 'debloquer' });
|
if (charge.evenement.etat === 'bloque') throw refus('PLAN_BLOQUE', {}, { geste: 'debloquer' });
|
||||||
|
|
@ -171,7 +155,7 @@ function ajouterParticipant(charge, { champs }) {
|
||||||
suivante.prochainsIds.participant = id + 1;
|
suivante.prochainsIds.participant = id + 1;
|
||||||
return {
|
return {
|
||||||
charge: suivante,
|
charge: suivante,
|
||||||
libelle: libelle('ENTREE_AJOUTER_PARTICIPANT', { personne: personneDe(participant) }),
|
libelle: libelle('ENTREE_AJOUTER_PARTICIPANT', { personne: nomAffiche(participant) }),
|
||||||
avertissements: [],
|
avertissements: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -192,9 +176,9 @@ function modifierParticipant(charge, { id, champs }) {
|
||||||
return {
|
return {
|
||||||
charge: avecParticipant(charge, id, () => apres),
|
charge: avecParticipant(charge, id, () => apres),
|
||||||
libelle: libelle('ENTREE_MODIFIER_PARTICIPANT', {
|
libelle: libelle('ENTREE_MODIFIER_PARTICIPANT', {
|
||||||
personne: personneDe(avant),
|
personne: nomAffiche(avant),
|
||||||
champs: changes,
|
champs: changes,
|
||||||
devenu: renomme ? personneDe(apres) : null,
|
devenu: renomme ? nomAffiche(apres) : null,
|
||||||
}),
|
}),
|
||||||
avertissements: [],
|
avertissements: [],
|
||||||
};
|
};
|
||||||
|
|
@ -206,7 +190,7 @@ function modifierParticipant(charge, { id, champs }) {
|
||||||
function bilanExclusion(avant, apres, participant) {
|
function bilanExclusion(avant, apres, participant) {
|
||||||
const reservations = avant.reservations.filter((reservation) => reservation.participant === participant.id).length;
|
const reservations = avant.reservations.filter((reservation) => reservation.participant === participant.id).length;
|
||||||
const titres = Math.abs(titresPourvus(avant) - titresPourvus(apres));
|
const titres = Math.abs(titresPourvus(avant) - titresPourvus(apres));
|
||||||
return { participant: participant.id, personne: personneDe(participant), reservations, titres };
|
return { participant: participant.id, personne: nomAffiche(participant), reservations, titres };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -255,7 +239,7 @@ function supprimerParticipant(charge, { id }) {
|
||||||
const reservations = charge.reservations.length - suivante.reservations.length;
|
const reservations = charge.reservations.length - suivante.reservations.length;
|
||||||
const details = {
|
const details = {
|
||||||
participant: id,
|
participant: id,
|
||||||
personne: personneDe(participant),
|
personne: nomAffiche(participant),
|
||||||
reservations,
|
reservations,
|
||||||
titres: titresPourvus(charge) - titresPourvus(suivante),
|
titres: titresPourvus(charge) - titresPourvus(suivante),
|
||||||
};
|
};
|
||||||
|
|
@ -423,28 +407,52 @@ function effacerPropositions(charge) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un registre réuni de plusieurs : chaque nom une fois, dans l'ordre des
|
||||||
|
* registres reçus, puis de chacun.
|
||||||
|
*
|
||||||
|
* @param {...Map<string, Function>} registres
|
||||||
|
* @returns {Map<string, Function>}
|
||||||
|
* @throws {RangeError} un nom porté par deux registres, qu'il nomme
|
||||||
|
*/
|
||||||
|
export function reunirCommandes(...registres) {
|
||||||
|
const reunies = new Map();
|
||||||
|
for (const registre of registres) {
|
||||||
|
for (const [nom, commande] of registre) {
|
||||||
|
if (reunies.has(nom)) throw new RangeError(`commandes : ${JSON.stringify(nom)} est dans deux registres`);
|
||||||
|
reunies.set(nom, commande);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reunies;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Le registre des commandes, par nom : chacune (charge, arguments) →
|
* Le registre des commandes, par nom : chacune (charge, arguments) →
|
||||||
* { charge, libelle, avertissements }, et importerParticipants y ajoute le
|
* { charge, libelle, avertissements }, et importerParticipants y ajoute le
|
||||||
* résumé de l'import.
|
* résumé de l'import. Il réunit les commandes de ce module, celles du
|
||||||
|
* mobilier et celles des places.
|
||||||
*
|
*
|
||||||
* @typedef {{code: string, details: Object}} Avertissement
|
* @typedef {{code: string, details: Object}} Avertissement
|
||||||
* @typedef {{charge: Object, libelle: string, avertissements: Avertissement[]}} Rendu
|
* @typedef {{charge: Object, libelle: string, avertissements: Avertissement[]}} Rendu
|
||||||
* @type {Map<string, (charge: Object, args: Object) => Rendu>}
|
* @type {Map<string, (charge: Object, args: Object) => Rendu>}
|
||||||
*/
|
*/
|
||||||
export const COMMANDES = new Map([
|
export const COMMANDES = reunirCommandes(
|
||||||
['ajouterParticipant', ajouterParticipant],
|
new Map([
|
||||||
['modifierParticipant', modifierParticipant],
|
['ajouterParticipant', ajouterParticipant],
|
||||||
['exclureParticipant', exclureParticipant],
|
['modifierParticipant', modifierParticipant],
|
||||||
['reintegrerParticipant', reintegrerParticipant],
|
['exclureParticipant', exclureParticipant],
|
||||||
['supprimerParticipant', supprimerParticipant],
|
['reintegrerParticipant', reintegrerParticipant],
|
||||||
['importerParticipants', importerParticipants],
|
['supprimerParticipant', supprimerParticipant],
|
||||||
['changerEtat', changerEtat],
|
['importerParticipants', importerParticipants],
|
||||||
['debloquer', debloquer],
|
['changerEtat', changerEtat],
|
||||||
['enregistrerGeneration', enregistrerGeneration],
|
['debloquer', debloquer],
|
||||||
['retenirProposition', retenirProposition],
|
['enregistrerGeneration', enregistrerGeneration],
|
||||||
['effacerPropositions', effacerPropositions],
|
['retenirProposition', retenirProposition],
|
||||||
]);
|
['effacerPropositions', effacerPropositions],
|
||||||
|
]),
|
||||||
|
COMMANDES_MOBILIER,
|
||||||
|
COMMANDES_PLACES,
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applique la commande nom à la charge : ce qu'elle rend, avertissements []
|
* Applique la commande nom à la charge : ce qu'elle rend, avertissements []
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,12 @@ import { rechercher } from '../moteur/recherche.js';
|
||||||
import { configurationDepuisCharge, creerCharge, etatDeduit } from '../stockage/document.js';
|
import { configurationDepuisCharge, creerCharge, etatDeduit } from '../stockage/document.js';
|
||||||
import { derive, fautes, formeNommee, planDepuisFichier } from '../stockage/placements.js';
|
import { derive, fautes, formeNommee, planDepuisFichier } from '../stockage/placements.js';
|
||||||
import { VERSION } from '../version.genere.js';
|
import { VERSION } from '../version.genere.js';
|
||||||
import { COMMANDES, ErreurCommande, appliquerCommande, chargeNeuve, formeNommeeDe } from './commandes.js';
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { chargements } from '../../test/chargements.js';
|
||||||
|
import { COMMANDES, ErreurCommande, appliquerCommande, chargeNeuve, formeNommeeDe, reunirCommandes } from './commandes.js';
|
||||||
|
import { COMMANDES_MOBILIER } from './commandes_mobilier.js';
|
||||||
|
import { COMMANDES_PLACES } from './commandes_places.js';
|
||||||
|
import { ErreurCommande as ErreurDesErreurs, refus } from './erreurs.js';
|
||||||
|
|
||||||
const V1 = VERSION.affichee;
|
const V1 = VERSION.affichee;
|
||||||
|
|
||||||
|
|
@ -603,3 +608,61 @@ describe('commandes : la charge reçue ne change jamais (§ 13.2)', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('commandes : les registres réunis et les erreurs partagées', () => {
|
||||||
|
test("reunirCommandes réunit des registres ; un nom de deux registres lève et le nomme", () => {
|
||||||
|
const premiere = () => null;
|
||||||
|
const seconde = () => null;
|
||||||
|
const reunies = reunirCommandes(new Map([['essaiUn', premiere]]), new Map([['essaiDeux', seconde]]));
|
||||||
|
assert.ok(reunies instanceof Map);
|
||||||
|
assert.deepEqual([...reunies.keys()], ['essaiUn', 'essaiDeux']);
|
||||||
|
assert.equal(reunies.get('essaiDeux'), seconde);
|
||||||
|
assert.throws(
|
||||||
|
() => reunirCommandes(new Map([['essaiPartage', premiere]]), new Map([['essaiPartage', seconde]])),
|
||||||
|
(erreur) => {
|
||||||
|
assert.match(erreur.message, /essaiPartage/);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("COMMANDES réunit les onze commandes d'A1 et les deux registres de F1, vides tant que leurs tâches ne les remplissent pas", () => {
|
||||||
|
assert.equal(COMMANDES.size, 11 + COMMANDES_MOBILIER.size + COMMANDES_PLACES.size);
|
||||||
|
assert.ok(COMMANDES_MOBILIER instanceof Map);
|
||||||
|
assert.ok(COMMANDES_PLACES instanceof Map);
|
||||||
|
for (const registre of [COMMANDES_MOBILIER, COMMANDES_PLACES]) {
|
||||||
|
for (const [nom, commande] of registre) assert.equal(COMMANDES.get(nom), commande, nom);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ErreurCommande importée de commandes.js est celle d'erreurs.js ; refus pose details.remede", () => {
|
||||||
|
assert.equal(ErreurCommande, ErreurDesErreurs);
|
||||||
|
const sans = refus('NOM_REQUIS');
|
||||||
|
assert.ok(sans instanceof ErreurCommande);
|
||||||
|
assert.equal(sans.name, 'ErreurCommande');
|
||||||
|
assert.equal(sans.code, 'NOM_REQUIS');
|
||||||
|
assert.deepEqual(sans.details, { remede: null });
|
||||||
|
const avec = refus('PLAN_BLOQUE', { table: 2 }, { geste: 'debloquer' });
|
||||||
|
assert.deepEqual(avec.details, { table: 2, remede: { geste: 'debloquer' } });
|
||||||
|
refuse(() => executer('debloquer', chargeEssai(), {}), 'PLAN_NON_BLOQUE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ni commandes_mobilier.js ni commandes_places.js n'atteignent commandes.js par leurs imports, à toute profondeur", () => {
|
||||||
|
const cible = new URL('./commandes.js', import.meta.url).href;
|
||||||
|
for (const depart of ['./commandes_mobilier.js', './commandes_places.js']) {
|
||||||
|
const vus = new Set();
|
||||||
|
const aVoir = [new URL(depart, import.meta.url).href];
|
||||||
|
while (aVoir.length > 0) {
|
||||||
|
const adresse = aVoir.pop();
|
||||||
|
if (vus.has(adresse)) continue;
|
||||||
|
vus.add(adresse);
|
||||||
|
for (const { specificateur, argument } of chargements(readFileSync(new URL(adresse), 'utf8'))) {
|
||||||
|
assert.equal(argument, undefined, `${adresse} : chargement calculé`);
|
||||||
|
if (specificateur.startsWith('.')) aVoir.push(new URL(specificateur, adresse).href);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(vus.size >= 1, `${depart} : aucun module parcouru`);
|
||||||
|
assert.ok(!vus.has(cible), `${depart} atteint commandes.js`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
9
src/application/commandes_mobilier.js
Normal file
9
src/application/commandes_mobilier.js
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Le registre des commandes du mobilier — tables, sièges, tours (§ 6, § 8.2),
|
||||||
|
// que commandes.js réunit aux autres. Ses imports n'atteignent jamais
|
||||||
|
// commandes.js : le registre réuni dépend de lui, non l'inverse.
|
||||||
|
|
||||||
|
/** @type {Map<string, (charge: Object, args: Object) => Object>} */
|
||||||
|
export const COMMANDES_MOBILIER = new Map();
|
||||||
9
src/application/commandes_places.js
Normal file
9
src/application/commandes_places.js
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Le registre des commandes des places — réservations, titres, pose (§ 5.8,
|
||||||
|
// § 5.9, § 8.2), que commandes.js réunit aux autres. Ses imports n'atteignent
|
||||||
|
// jamais commandes.js : le registre réuni dépend de lui, non l'inverse.
|
||||||
|
|
||||||
|
/** @type {Map<string, (charge: Object, args: Object) => Object>} */
|
||||||
|
export const COMMANDES_PLACES = new Map();
|
||||||
29
src/application/csv.js
Normal file
29
src/application/csv.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Le CSV tel que l'interface l'atteint (§ 13.4) : elle passe par
|
||||||
|
// l'application, jamais par src/csv. Ce module réexporte les fonctions de
|
||||||
|
// src/csv dont un écran a besoin, telles quelles, et lie le motif du CSV des
|
||||||
|
// refus au libellé français : l'interface ne choisit pas ce motif, qui est
|
||||||
|
// une donnée du fichier écrit, jamais un texte de la langue témoin.
|
||||||
|
import { exporterRefus as exporterRefusCsv } from '../csv/import.js';
|
||||||
|
import { libelle } from './libelles.js';
|
||||||
|
|
||||||
|
export { apercevoir } from '../csv/apercu.js';
|
||||||
|
export { decoder } from '../csv/encodage.js';
|
||||||
|
export { exporterParticipants } from '../csv/export.js';
|
||||||
|
export { bilanRemplacement } from '../csv/import.js';
|
||||||
|
export { cleNormalisee } from '../csv/normalisation.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le CSV des refus d'un aperçu (exporterRefus de src/csv), chaque motif
|
||||||
|
* composé par libelle de son code et de ses détails.
|
||||||
|
*
|
||||||
|
* @param {import('../csv/apercu.js').Apercu} apercu
|
||||||
|
* @param {Array<Object>} [refusees] par défaut ceux de l'aperçu ; après
|
||||||
|
* l'application, resume.refusees
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
export function exporterRefus(apercu, refusees = apercu.refusees) {
|
||||||
|
return exporterRefusCsv(apercu, libelle, refusees);
|
||||||
|
}
|
||||||
64
src/application/csv.test.js
Normal file
64
src/application/csv.test.js
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Épreuves du relais CSV de l'application (§ 13.4, § 10.1) : l'interface
|
||||||
|
// atteint src/csv par ce module, qui réexporte ses fonctions telles quelles
|
||||||
|
// et lie le motif du CSV des refus au libellé français. Les noms sont
|
||||||
|
// inventés.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
|
import * as apercu from '../csv/apercu.js';
|
||||||
|
import * as encodage from '../csv/encodage.js';
|
||||||
|
import * as exportCsv from '../csv/export.js';
|
||||||
|
import * as importCsv from '../csv/import.js';
|
||||||
|
import { decouper } from '../csv/lecture.js';
|
||||||
|
import * as normalisation from '../csv/normalisation.js';
|
||||||
|
import * as relais from './csv.js';
|
||||||
|
import { libelle } from './libelles.js';
|
||||||
|
|
||||||
|
describe('csv : le relais de src/csv pour l’interface (§ 13.4)', () => {
|
||||||
|
test('decoder, apercevoir, exporterParticipants, bilanRemplacement et cleNormalisee sont ceux de src/csv', () => {
|
||||||
|
const attendus = [
|
||||||
|
['decoder', encodage.decoder],
|
||||||
|
['apercevoir', apercu.apercevoir],
|
||||||
|
['exporterParticipants', exportCsv.exporterParticipants],
|
||||||
|
['bilanRemplacement', importCsv.bilanRemplacement],
|
||||||
|
['cleNormalisee', normalisation.cleNormalisee],
|
||||||
|
];
|
||||||
|
assert.equal(attendus.length, 5);
|
||||||
|
for (const [nom, fonction] of attendus) {
|
||||||
|
assert.equal(typeof fonction, 'function', nom);
|
||||||
|
assert.equal(relais[nom], fonction, nom);
|
||||||
|
}
|
||||||
|
assert.deepEqual(Object.keys(relais).sort(), [
|
||||||
|
'apercevoir', 'bilanRemplacement', 'cleNormalisee', 'decoder', 'exporterParticipants', 'exporterRefus',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exporterRefus écrit sous l'en-tête motif exactement libelle(code, details) d'une ligne refusée NOM_ABSENT", () => {
|
||||||
|
const texte = ['nom;prenom', 'Ombrelle;Iris', ';Théo', 'Pervenche;Ondine', ''].join('\r\n');
|
||||||
|
const lu = relais.apercevoir(texte);
|
||||||
|
assert.equal(lu.refusees.length, 1);
|
||||||
|
const [{ code, brut, ...details }] = lu.refusees;
|
||||||
|
assert.equal(code, 'NOM_ABSENT');
|
||||||
|
const { enregistrements } = decouper(relais.decoder(relais.exporterRefus(lu)).texte, ';');
|
||||||
|
assert.equal(enregistrements.length, 2);
|
||||||
|
const [entete, ligne] = enregistrements;
|
||||||
|
const rang = entete.indexOf('motif');
|
||||||
|
assert.ok(rang >= 0, entete.join(';'));
|
||||||
|
assert.equal(ligne[rang], libelle('NOM_ABSENT', details));
|
||||||
|
assert.equal(ligne[entete.indexOf('ligne')], '3');
|
||||||
|
assert.ok(brut.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exporterRefus écrit les refus reçus en second argument, à la place de ceux de l’aperçu', () => {
|
||||||
|
const lu = relais.apercevoir(['nom;exclu', 'Ombrelle;peut-être', ''].join('\r\n'));
|
||||||
|
assert.equal(lu.refusees.length, 1);
|
||||||
|
const vide = decouper(relais.decoder(relais.exporterRefus(lu, [])).texte, ';').enregistrements;
|
||||||
|
assert.deepEqual(vide, [['nom', 'exclu', 'ligne', 'motif']]);
|
||||||
|
const [entete, ligne] = decouper(relais.decoder(relais.exporterRefus(lu, lu.refusees)).texte, ';').enregistrements;
|
||||||
|
const [{ code, brut, ...details }] = lu.refusees;
|
||||||
|
assert.equal(ligne[entete.indexOf('motif')], libelle(code, details));
|
||||||
|
assert.ok(brut.length > 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
36
src/application/erreurs.js
Normal file
36
src/application/erreurs.js
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Le refus d'une commande (§ 8.2, § 14.6), que lèvent les registres de
|
||||||
|
// commandes et leurs contrôles : un code, et des détails qui portent le
|
||||||
|
// remède, details.remede, null quand aucun geste ne lève le refus.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le refus d'une commande : son code, et des détails que l'appelant lit pour
|
||||||
|
* agir, remede compris. Le message, code et détails en JSON, sert aux traces ;
|
||||||
|
* le texte affiché vient de la table des libellés (§ 14.6).
|
||||||
|
*/
|
||||||
|
export class ErreurCommande extends Error {
|
||||||
|
/**
|
||||||
|
* @param {string} code
|
||||||
|
* @param {Object} [details]
|
||||||
|
*/
|
||||||
|
constructor(code, details = {}) {
|
||||||
|
super(`${code} ${JSON.stringify(details)}`);
|
||||||
|
this.code = code;
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ErreurCommande.prototype.name = 'ErreurCommande';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le refus de code donné : ses détails, puis le remède, null sans lui.
|
||||||
|
*
|
||||||
|
* @param {string} code
|
||||||
|
* @param {Object} [details]
|
||||||
|
* @param {Object|null} [remede]
|
||||||
|
* @returns {ErreurCommande}
|
||||||
|
*/
|
||||||
|
export function refus(code, details = {}, remede = null) {
|
||||||
|
return new ErreurCommande(code, { ...details, remede });
|
||||||
|
}
|
||||||
149
src/application/format.js
Normal file
149
src/application/format.js
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// La mise en forme des nombres, des durées, des dates et des horodatages
|
||||||
|
// (§ 5.4, § 5.10, § 11.7, § 18.4). Aucune fonction ne lit la locale du
|
||||||
|
// système : les chiffres se groupent par trois, séparés d'une espace fine
|
||||||
|
// insécable (U+202F), la décimale suit une virgule, un négatif porte le signe
|
||||||
|
// moins (U+2212). Tout texte rendu qui porte une lettre — unité, mois, ordre
|
||||||
|
// de grandeur — vient d'une clé format.… par t, en un seul appel : en langue
|
||||||
|
// témoin, il est encadré de la marque une fois. Un nombre, qui ne porte
|
||||||
|
// aucune lettre, n'y passe pas.
|
||||||
|
|
||||||
|
const ESPACE_FINE = '\u{202F}';
|
||||||
|
const MOINS = '\u{2212}';
|
||||||
|
|
||||||
|
// L'échelle des ordres de grandeur d'une durée (§ 5.10) : chaque pas en
|
||||||
|
// secondes et sa clé. Sous le premier seuil, moins d'une minute ; dès le
|
||||||
|
// dernier, plusieurs heures ; entre les deux, le pas le plus proche en
|
||||||
|
// rapport : la frontière de deux pas voisins a et b est √(a·b), comparée
|
||||||
|
// exactement, s² contre a·b.
|
||||||
|
const MOINS_D_UNE_MINUTE = 60;
|
||||||
|
const PLUSIEURS_HEURES = 5400;
|
||||||
|
const PAS = [
|
||||||
|
[120, 'format.duree.deuxMinutes'],
|
||||||
|
[300, 'format.duree.cinqMinutes'],
|
||||||
|
[600, 'format.duree.dixMinutes'],
|
||||||
|
[1200, 'format.duree.vingtMinutes'],
|
||||||
|
[1800, 'format.duree.demiHeure'],
|
||||||
|
[3600, 'format.duree.uneHeure'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||||
|
const HORODATAGE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})[+-](\d{2}):(\d{2})$/;
|
||||||
|
|
||||||
|
// Le nombre de jours d'un mois du calendrier grégorien.
|
||||||
|
function joursDuMois(annee, mois) {
|
||||||
|
if (mois === 2) return annee % 4 === 0 && (annee % 100 !== 0 || annee % 400 === 0) ? 29 : 28;
|
||||||
|
return [4, 6, 9, 11].includes(mois) ? 30 : 31;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Année, mois et jour d'une date AAAA-MM-JJ du calendrier, ou RangeError.
|
||||||
|
function lireDate(annee, mois, jour, texte) {
|
||||||
|
const lue = { annee: Number(annee), mois: Number(mois), jour: Number(jour) };
|
||||||
|
if (lue.mois < 1 || lue.mois > 12 || lue.jour < 1 || lue.jour > joursDuMois(lue.annee, lue.mois)) {
|
||||||
|
throw new RangeError(`format : ${JSON.stringify(texte)} n'est pas un jour du calendrier`);
|
||||||
|
}
|
||||||
|
return lue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un nombre fini, ou l'erreur qui dit pourquoi il ne se met pas en forme.
|
||||||
|
function exigerFini(valeur, quoi) {
|
||||||
|
if (typeof valeur !== 'number') throw new TypeError(`format : ${quoi} doit être un nombre, reçu ${String(valeur)}`);
|
||||||
|
if (!Number.isFinite(valeur)) throw new RangeError(`format : ${quoi} non fini, ${valeur}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les fonctions de mise en forme, dont chaque texte vient de t.
|
||||||
|
*
|
||||||
|
* @param {(cle: string, details?: Object) => string} t la traduction
|
||||||
|
* @returns {{
|
||||||
|
* nombre: (valeur: number|null, options?: {decimales?: number}) => string,
|
||||||
|
* duree: (ms: number) => string,
|
||||||
|
* ordreDeGrandeur: (secondes: number) => string,
|
||||||
|
* date: (date: string) => string,
|
||||||
|
* horodatage: (horodatage: string) => string,
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
export function creerFormat(t) {
|
||||||
|
// Les chiffres d'un nombre fini, arrondi à decimales chiffres après la
|
||||||
|
// virgule, sans passer par la traduction.
|
||||||
|
function chiffres(valeur, decimales) {
|
||||||
|
if (!Number.isInteger(decimales) || decimales < 0 || decimales > 20) {
|
||||||
|
throw new RangeError(`format : décimales hors de 0 à 20, ${decimales}`);
|
||||||
|
}
|
||||||
|
const ecrit = Math.abs(valeur).toFixed(decimales);
|
||||||
|
if (ecrit.includes('e')) throw new RangeError(`format : ${valeur} dépasse ce qui s'écrit en chiffres`);
|
||||||
|
const [entiere, fraction] = ecrit.split('.');
|
||||||
|
const groupee = entiere.replace(/\B(?=(\d{3})+$)/g, ESPACE_FINE);
|
||||||
|
const nul = /^[0.]*$/.test(ecrit);
|
||||||
|
const signe = valeur < 0 && !nul ? MOINS : '';
|
||||||
|
return `${signe}${groupee}${fraction === undefined ? '' : `,${fraction}`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Un nombre ; null, une valeur absente, s'écrit format.vide, jamais 0. */
|
||||||
|
function nombre(valeur, { decimales = 0 } = {}) {
|
||||||
|
if (valeur === null) return t('format.vide');
|
||||||
|
exigerFini(valeur, 'nombre');
|
||||||
|
return chiffres(valeur, decimales);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Une durée en millisecondes : sous la minute, au dixième de seconde, un
|
||||||
|
* compte rond sans décimale ; sous l'heure, minutes et secondes entières ;
|
||||||
|
* au-delà, heures et minutes entières. Un arrondi qui atteint l'unité
|
||||||
|
* suivante s'écrit dans elle.
|
||||||
|
*/
|
||||||
|
function duree(ms) {
|
||||||
|
exigerFini(ms, 'durée');
|
||||||
|
if (ms < 0) throw new RangeError(`format : durée négative, ${ms}`);
|
||||||
|
const dixiemes = Math.round(ms / 100);
|
||||||
|
if (dixiemes < 600) {
|
||||||
|
const secondes = dixiemes % 10 === 0 ? chiffres(dixiemes / 10, 0) : chiffres(dixiemes / 10, 1);
|
||||||
|
return t('format.duree.secondes', { secondes });
|
||||||
|
}
|
||||||
|
const secondes = Math.round(ms / 1000);
|
||||||
|
if (secondes < 3600) {
|
||||||
|
return t('format.duree.minutes', { minutes: Math.floor(secondes / 60), secondes: secondes % 60 });
|
||||||
|
}
|
||||||
|
const minutes = Math.round(ms / 60_000);
|
||||||
|
return t('format.duree.heures', { heures: chiffres(Math.floor(minutes / 60), 0), minutes: minutes % 60 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** L'ordre de grandeur d'une durée en secondes (§ 5.10). */
|
||||||
|
function ordreDeGrandeur(secondes) {
|
||||||
|
exigerFini(secondes, 'durée');
|
||||||
|
if (secondes < 0) throw new RangeError(`format : durée négative, ${secondes}`);
|
||||||
|
if (secondes < MOINS_D_UNE_MINUTE) return t('format.duree.moinsDUneMinute');
|
||||||
|
if (secondes >= PLUSIEURS_HEURES) return t('format.duree.plusieursHeures');
|
||||||
|
const carre = secondes * secondes;
|
||||||
|
let rang = 0;
|
||||||
|
while (rang + 1 < PAS.length && carre >= PAS[rang][0] * PAS[rang + 1][0]) rang += 1;
|
||||||
|
return t(PAS[rang][1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Une date AAAA-MM-JJ en lettres, « 1er » pour le premier du mois. */
|
||||||
|
function date(texte) {
|
||||||
|
const morceaux = DATE.exec(String(texte));
|
||||||
|
if (morceaux === null) throw new RangeError(`format : date AAAA-MM-JJ attendue, reçu ${JSON.stringify(texte)}`);
|
||||||
|
return t('format.date', lireDate(...morceaux.slice(1), texte));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un horodatage AAAA-MM-JJTHH:MM:SS±HH:MM en lettres, à l'heure locale
|
||||||
|
* qu'il porte : la date, puis l'heure et la minute.
|
||||||
|
*/
|
||||||
|
function horodatage(texte) {
|
||||||
|
const morceaux = HORODATAGE.exec(String(texte));
|
||||||
|
if (morceaux === null) {
|
||||||
|
throw new RangeError(`format : horodatage AAAA-MM-JJTHH:MM:SS±HH:MM attendu, reçu ${JSON.stringify(texte)}`);
|
||||||
|
}
|
||||||
|
const [heure, minute, seconde, decalageH, decalageM] = morceaux.slice(4).map(Number);
|
||||||
|
if (heure > 23 || minute > 59 || seconde > 59 || decalageH > 23 || decalageM > 59) {
|
||||||
|
throw new RangeError(`format : ${JSON.stringify(texte)} n'est pas une heure du jour`);
|
||||||
|
}
|
||||||
|
return t('format.horodatage', { ...lireDate(...morceaux.slice(1, 4), texte), heure, minute });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nombre, duree, ordreDeGrandeur, date, horodatage };
|
||||||
|
}
|
||||||
177
src/application/format.test.js
Normal file
177
src/application/format.test.js
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Épreuves de la mise en forme (§ 5.4, § 5.10, § 11.7, § 18.4) : les valeurs
|
||||||
|
// du contrat, exactement ; « — » pour une valeur absente, jamais « 0 » ; un
|
||||||
|
// nombre non fini lève ; l'échelle des ordres de grandeur et ses frontières ;
|
||||||
|
// en langue témoin, un texte qui porte une lettre est encadré de la marque.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
|
import { creerFormat } from './format.js';
|
||||||
|
import { TABLES_APPLICATION } from './libelles.js';
|
||||||
|
import { MARQUE_TEMOIN, creerTraduction } from './traduction.js';
|
||||||
|
|
||||||
|
const { ouverture: O, fermeture: F } = MARQUE_TEMOIN;
|
||||||
|
const FINE = '\u{202F}';
|
||||||
|
|
||||||
|
const francais = creerTraduction(TABLES_APPLICATION);
|
||||||
|
const format = creerFormat(francais.t);
|
||||||
|
const temoin = creerFormat(creerTraduction(TABLES_APPLICATION, { langue: 'temoin' }).t);
|
||||||
|
|
||||||
|
describe('format : les nombres (§ 5.4, § 11.7)', () => {
|
||||||
|
test('les valeurs du contrat : « — » pour null, espace fine insécable entre les milliers, virgule décimale', () => {
|
||||||
|
assert.equal(format.nombre(null), '\u{2014}');
|
||||||
|
assert.equal(format.nombre(null), francais.t('format.vide'));
|
||||||
|
assert.equal(format.nombre(1234.5, { decimales: 1 }), `1${FINE}234,5`);
|
||||||
|
assert.equal(format.nombre(0), '0');
|
||||||
|
assert.notEqual(format.nombre(null), '0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("l'arrondi suit les décimales demandées ; les grands nombres se groupent par trois", () => {
|
||||||
|
assert.equal(format.nombre(2.25, { decimales: 0 }), '2');
|
||||||
|
assert.equal(format.nombre(2.75), '3');
|
||||||
|
assert.equal(format.nombre(999), '999');
|
||||||
|
assert.equal(format.nombre(1000), `1${FINE}000`);
|
||||||
|
assert.equal(format.nombre(1234567.891, { decimales: 2 }), `1${FINE}234${FINE}567,89`);
|
||||||
|
assert.equal(format.nombre(0.5, { decimales: 2 }), '0,50');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un négatif porte le signe moins typographique ; zéro négatif s’écrit 0', () => {
|
||||||
|
assert.equal(format.nombre(-1234), `\u{2212}1${FINE}234`);
|
||||||
|
assert.equal(format.nombre(-0), '0');
|
||||||
|
assert.equal(format.nombre(-0.04, { decimales: 1 }), '0,0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Infinity et NaN lèvent ; une valeur qui n’est pas un nombre lève ; des décimales hors de 0 à 20 lèvent', () => {
|
||||||
|
for (const valeur of [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NaN]) {
|
||||||
|
assert.throws(() => format.nombre(valeur), RangeError, String(valeur));
|
||||||
|
}
|
||||||
|
for (const valeur of [undefined, '12', 12n]) assert.throws(() => format.nombre(valeur), TypeError, String(valeur));
|
||||||
|
for (const decimales of [-1, 1.5, 21]) assert.throws(() => format.nombre(1, { decimales }), RangeError, String(decimales));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("en langue témoin, la valeur absente est encadrée ; un nombre, qui ne porte aucune lettre, ne l'est pas", () => {
|
||||||
|
assert.equal(temoin.nombre(null), `${O}\u{2014}${F}`);
|
||||||
|
assert.equal(temoin.nombre(1234.5, { decimales: 1 }), `1${FINE}234,5`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('format : les durées (§ 5.10)', () => {
|
||||||
|
test('les valeurs du contrat', () => {
|
||||||
|
assert.equal(format.duree(1800), '1,8 s');
|
||||||
|
assert.equal(format.duree(150000), '2 min 30 s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sous la minute au dixième de seconde, puis minutes et secondes, puis heures et minutes ; une part nulle se tait", () => {
|
||||||
|
assert.equal(format.duree(0), '0 s');
|
||||||
|
assert.equal(format.duree(12000), '12 s');
|
||||||
|
assert.equal(format.duree(59940), '59,9 s');
|
||||||
|
assert.equal(format.duree(59960), '1 min');
|
||||||
|
assert.equal(format.duree(120000), '2 min');
|
||||||
|
assert.equal(format.duree(3599400), '59 min 59 s');
|
||||||
|
assert.equal(format.duree(3599600), '1 h');
|
||||||
|
assert.equal(format.duree(5400000), '1 h 30 min');
|
||||||
|
assert.equal(format.duree(36000000), '10 h');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une durée négative ou non finie lève', () => {
|
||||||
|
for (const ms of [-1, Number.NaN, Number.POSITIVE_INFINITY]) assert.throws(() => format.duree(ms), RangeError, String(ms));
|
||||||
|
assert.throws(() => format.duree(null), TypeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('en langue témoin, une durée est encadrée de la marque, une fois', () => {
|
||||||
|
assert.equal(temoin.duree(1800), `${O}1,8 s${F}`);
|
||||||
|
assert.equal(temoin.duree(150000), `${O}2 min 30 s${F}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("format : l'ordre de grandeur d'une durée (§ 5.10)", () => {
|
||||||
|
const ATTENDUS = [
|
||||||
|
[30, "moins d'une minute"],
|
||||||
|
[59, "moins d'une minute"],
|
||||||
|
[60, 'environ deux minutes'],
|
||||||
|
[848, 'environ dix minutes'],
|
||||||
|
[849, 'environ vingt minutes'],
|
||||||
|
[1200, 'environ vingt minutes'],
|
||||||
|
[1900, 'environ une demi-heure'],
|
||||||
|
[5399, 'environ une heure'],
|
||||||
|
[5400, 'plusieurs heures'],
|
||||||
|
[7200, 'plusieurs heures'],
|
||||||
|
];
|
||||||
|
|
||||||
|
test("les valeurs du contrat, frontières comprises", () => {
|
||||||
|
assert.equal(ATTENDUS.length, 10);
|
||||||
|
for (const [secondes, texte] of ATTENDUS) assert.equal(format.ordreDeGrandeur(secondes), texte, String(secondes));
|
||||||
|
assert.equal(format.ordreDeGrandeur(1200), francais.t('format.duree.vingtMinutes'));
|
||||||
|
assert.equal(francais.t('format.duree.vingtMinutes'), 'environ vingt minutes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chaque pas a sa frontière en √(a·b) : le dernier entier de chaque pas, puis le premier du suivant', () => {
|
||||||
|
// Les pas en secondes, et le premier entier au-delà de chaque frontière.
|
||||||
|
const pas = [120, 300, 600, 1200, 1800, 3600];
|
||||||
|
const textes = pas.map((s) => format.ordreDeGrandeur(s));
|
||||||
|
assert.equal(new Set(textes).size, 6);
|
||||||
|
for (let i = 0; i + 1 < pas.length; i += 1) {
|
||||||
|
const produit = pas[i] * pas[i + 1];
|
||||||
|
let premier = Math.ceil(Math.sqrt(produit));
|
||||||
|
while (premier * premier < produit) premier += 1;
|
||||||
|
while ((premier - 1) * (premier - 1) >= produit) premier -= 1;
|
||||||
|
assert.equal(format.ordreDeGrandeur(premier - 1), textes[i], `${premier - 1} s`);
|
||||||
|
assert.equal(format.ordreDeGrandeur(premier), textes[i + 1], `${premier} s`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('-1, NaN et Infinity lèvent', () => {
|
||||||
|
for (const secondes of [-1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||||
|
assert.throws(() => format.ordreDeGrandeur(secondes), RangeError, String(secondes));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('en langue témoin, un ordre de grandeur est encadré de la marque', () => {
|
||||||
|
assert.equal(temoin.ordreDeGrandeur(1200), `${O}environ vingt minutes${F}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('format : les dates et les horodatages (§ 18.4)', () => {
|
||||||
|
test('les valeurs du contrat : la date en lettres, « 1er » pour le premier du mois', () => {
|
||||||
|
assert.equal(format.date('2026-10-06'), '6 octobre 2026');
|
||||||
|
assert.equal(format.date('2026-10-01'), '1er octobre 2026');
|
||||||
|
assert.equal(format.horodatage('2026-10-06T19:02:11-04:00'), '6 octobre 2026, 19 h 02');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chaque mois a son nom ; l'heure du matin s'écrit sans zéro de tête, à l'heure que l'horodatage porte", () => {
|
||||||
|
const mois = Array.from({ length: 12 }, (_, i) => format.date(`2031-${String(i + 1).padStart(2, '0')}-17`));
|
||||||
|
assert.equal(mois.length, 12);
|
||||||
|
assert.deepEqual(mois.map((texte) => texte.split(' ')[1]), [
|
||||||
|
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
|
||||||
|
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
|
||||||
|
]);
|
||||||
|
assert.equal(format.horodatage('2031-05-01T08:05:00+02:00'), '1er mai 2031, 8 h 05');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une date ou un horodatage hors de leur forme, ou hors du calendrier, lèvent", () => {
|
||||||
|
for (const date of ['2026-10-6', '06/10/2026', '2026-13-01', '2026-02-29', '2026-04-31', '2026-10-00', null]) {
|
||||||
|
assert.throws(() => format.date(date), RangeError, String(date));
|
||||||
|
}
|
||||||
|
assert.equal(format.date('2028-02-29'), '29 février 2028');
|
||||||
|
for (const horodatage of ['2026-10-06T19:02:11', '2026-10-06T24:00:00-04:00', '2026-10-06T19:60:00-04:00', '2026-10-06']) {
|
||||||
|
assert.throws(() => format.horodatage(horodatage), RangeError, horodatage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('en langue témoin, une date et un horodatage sont encadrés de la marque, une fois', () => {
|
||||||
|
assert.equal(temoin.date('2026-10-01'), `${O}1er octobre 2026${F}`);
|
||||||
|
assert.equal(temoin.horodatage('2026-10-06T19:02:11-04:00'), `${O}6 octobre 2026, 19 h 02${F}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('format : rien ne vient de la locale du système', () => {
|
||||||
|
test("les textes qui portent une lettre viennent des clés format.… de la table", () => {
|
||||||
|
const cles = francais.cles().filter((cle) => cle.startsWith('format.'));
|
||||||
|
assert.ok(cles.length > 0, 'aucune clé format.');
|
||||||
|
for (const cle of ['format.vide', 'format.duree.vingtMinutes']) assert.ok(cles.includes(cle), cle);
|
||||||
|
// Une table substituée change le texte rendu : il ne vient que d'elle.
|
||||||
|
const autre = creerFormat((cle, details) => (cle === 'format.vide' ? 'néant' : francais.t(cle, details)));
|
||||||
|
assert.equal(autre.nombre(null), 'néant');
|
||||||
|
});
|
||||||
|
});
|
||||||
193
src/application/horloge.js
Normal file
193
src/application/horloge.js
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// L'horloge de l'application (§ 19.4, § 14.7) : la seule lecture de
|
||||||
|
// l'horloge du moteur d'exécution, et les seules minuteries. Hors du moteur,
|
||||||
|
// src/application et src/interface lisent l'heure et arment leurs minuteries
|
||||||
|
// par l'horloge qu'on leur injecte, jamais autrement : creerHorlogeSysteme est
|
||||||
|
// le seul code de ces deux couches qui appelle new Date, performance.now,
|
||||||
|
// setTimeout et clearTimeout. Les épreuves injectent l'horloge pilotée, où
|
||||||
|
// rien n'avance seul.
|
||||||
|
//
|
||||||
|
// Une horloge est quatre fermetures, appelables détachées de leur objet :
|
||||||
|
// horodatage() AAAA-MM-JJTHH:MM:SS±HH:MM, heure locale et décalage
|
||||||
|
// instant() millisecondes, monotone : les durées
|
||||||
|
// planifier(fn, ms) arme une minuterie, rend un identifiant opaque — un
|
||||||
|
// nombre sous le navigateur et sous l'horloge pilotée,
|
||||||
|
// un Timeout sous node —, que l'appelant ne fait que
|
||||||
|
// rendre à annuler
|
||||||
|
// annuler(id) un identifiant inconnu ou déjà parti : sans effet
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Horloge
|
||||||
|
* @property {() => string} horodatage
|
||||||
|
* @property {() => number} instant
|
||||||
|
* @property {(fn: () => void, ms: number) => unknown} planifier
|
||||||
|
* @property {(id: unknown) => void} annuler
|
||||||
|
*/
|
||||||
|
|
||||||
|
const deux = (n) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
|
// Un décalage en minutes à l'est de UTC, écrit ±HH:MM.
|
||||||
|
function ecrireDecalage(minutes) {
|
||||||
|
const absolu = Math.abs(minutes);
|
||||||
|
return `${minutes < 0 ? '-' : '+'}${deux(Math.floor(absolu / 60))}:${deux(absolu % 60)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les contrôles communs aux deux horloges : un rappel qui se lance, un délai
|
||||||
|
// fini et positif.
|
||||||
|
function exigerMinuterie(fn, ms) {
|
||||||
|
if (typeof fn !== 'function') throw new TypeError('horloge : planifier attend une fonction');
|
||||||
|
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) {
|
||||||
|
throw new RangeError(`horloge : délai fini et positif attendu, reçu ${String(ms)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'horloge du moteur d'exécution : l'heure locale du système, son décalage,
|
||||||
|
* le compteur monotone et les minuteries du navigateur ou de node.
|
||||||
|
*
|
||||||
|
* @returns {Horloge}
|
||||||
|
*/
|
||||||
|
export function creerHorlogeSysteme() {
|
||||||
|
const horodatage = () => {
|
||||||
|
const maintenant = new Date();
|
||||||
|
const jour = `${maintenant.getFullYear()}-${deux(maintenant.getMonth() + 1)}-${deux(maintenant.getDate())}`;
|
||||||
|
const heure = `${deux(maintenant.getHours())}:${deux(maintenant.getMinutes())}:${deux(maintenant.getSeconds())}`;
|
||||||
|
return `${jour}T${heure}${ecrireDecalage(-maintenant.getTimezoneOffset())}`;
|
||||||
|
};
|
||||||
|
const instant = () => performance.now();
|
||||||
|
const planifier = (fn, ms) => {
|
||||||
|
exigerMinuterie(fn, ms);
|
||||||
|
return setTimeout(fn, ms);
|
||||||
|
};
|
||||||
|
const annuler = (id) => {
|
||||||
|
clearTimeout(id);
|
||||||
|
};
|
||||||
|
return { horodatage, instant, planifier, annuler };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Le calendrier de l'horloge pilotée ---------------------------------------
|
||||||
|
//
|
||||||
|
// L'horloge pilotée compte en millisecondes depuis 1970-01-01T00:00:00 UTC et
|
||||||
|
// convertit par l'arithmétique du calendrier grégorien proleptique, sans lire
|
||||||
|
// l'horloge du système. Les jours se comptent depuis cette origine.
|
||||||
|
|
||||||
|
const MS_PAR_JOUR = 86_400_000;
|
||||||
|
const FORME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})([+-])(\d{2}):(\d{2})$/;
|
||||||
|
|
||||||
|
// Le nombre de jours depuis 1970-01-01 d'une date civile.
|
||||||
|
function joursDepuisOrigine(annee, mois, jour) {
|
||||||
|
const a = mois <= 2 ? annee - 1 : annee;
|
||||||
|
const ere = Math.floor(a / 400);
|
||||||
|
const anneeDEre = a - ere * 400;
|
||||||
|
const jourDAnnee = Math.floor((153 * (mois + (mois > 2 ? -3 : 9)) + 2) / 5) + jour - 1;
|
||||||
|
const jourDEre = anneeDEre * 365 + Math.floor(anneeDEre / 4) - Math.floor(anneeDEre / 100) + jourDAnnee;
|
||||||
|
return ere * 146_097 + jourDEre - 719_468;
|
||||||
|
}
|
||||||
|
|
||||||
|
// La date civile du jour compté depuis 1970-01-01.
|
||||||
|
function dateCivile(jours) {
|
||||||
|
const z = jours + 719_468;
|
||||||
|
const ere = Math.floor(z / 146_097);
|
||||||
|
const jourDEre = z - ere * 146_097;
|
||||||
|
const anneeDEre = Math.floor(
|
||||||
|
(jourDEre - Math.floor(jourDEre / 1460) + Math.floor(jourDEre / 36_524) - Math.floor(jourDEre / 146_096)) / 365,
|
||||||
|
);
|
||||||
|
const jourDAnnee = jourDEre - (365 * anneeDEre + Math.floor(anneeDEre / 4) - Math.floor(anneeDEre / 100));
|
||||||
|
const moisDecale = Math.floor((5 * jourDAnnee + 2) / 153);
|
||||||
|
const jour = jourDAnnee - Math.floor((153 * moisDecale + 2) / 5) + 1;
|
||||||
|
const mois = moisDecale < 10 ? moisDecale + 3 : moisDecale - 9;
|
||||||
|
return { annee: anneeDEre + ere * 400 + (mois <= 2 ? 1 : 0), mois, jour };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un horodatage de départ lu : l'instant UTC en millisecondes et le décalage
|
||||||
|
// en minutes à l'est de UTC. Une date hors du calendrier lève.
|
||||||
|
function lireDepart(depart) {
|
||||||
|
const morceaux = FORME.exec(String(depart));
|
||||||
|
if (morceaux === null) throw new RangeError(`horloge : départ AAAA-MM-JJTHH:MM:SS±HH:MM attendu, reçu ${depart}`);
|
||||||
|
const [annee, mois, jour, heure, minute, seconde] = morceaux.slice(1, 7).map(Number);
|
||||||
|
const decalage = (morceaux[7] === '-' ? -1 : 1) * (Number(morceaux[8]) * 60 + Number(morceaux[9]));
|
||||||
|
const jours = joursDepuisOrigine(annee, mois, jour);
|
||||||
|
const relue = dateCivile(jours);
|
||||||
|
if (mois < 1 || mois > 12 || relue.mois !== mois || relue.jour !== jour || heure > 23 || minute > 59 || seconde > 59) {
|
||||||
|
throw new RangeError(`horloge : ${depart} n'est pas un instant du calendrier`);
|
||||||
|
}
|
||||||
|
const local = jours * MS_PAR_JOUR + ((heure * 60 + minute) * 60 + seconde) * 1000;
|
||||||
|
return { utc: local - decalage * 60_000, decalage };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Une horloge où rien n'avance seul. avancer(ms) est synchrone : il
|
||||||
|
* déclenche les minuteries échues dans l'ordre de leur échéance, puis de
|
||||||
|
* leur création, chacune à l'instant de son échéance — horodatage() et
|
||||||
|
* instant() y rendent cette échéance — ; une minuterie qu'un rappel arme et
|
||||||
|
* qui échoit dans la fenêtre part dans le même avancer ; à son retour,
|
||||||
|
* l'horloge a avancé de ms exactement. Il n'attend aucune promesse : l'épreuve
|
||||||
|
* attend elle-même ce que les rappels lancent. Un rappel qui lève arrête
|
||||||
|
* avancer à son échéance, et l'erreur remonte.
|
||||||
|
*
|
||||||
|
* horodatage() garde le décalage du départ ; instant() part de 0.
|
||||||
|
*
|
||||||
|
* @param {{depart?: string}} [options]
|
||||||
|
* @returns {Horloge & {avancer: (ms: number) => undefined}}
|
||||||
|
* @throws {RangeError} un départ hors de sa forme ou du calendrier
|
||||||
|
*/
|
||||||
|
export function creerHorlogePilotee({ depart = '2026-10-06T19:00:00-04:00' } = {}) {
|
||||||
|
const { utc, decalage } = lireDepart(depart);
|
||||||
|
let ecoule = 0;
|
||||||
|
let prochainId = 1;
|
||||||
|
// Les minuteries en attente, par identifiant : échéance et rappel. Les
|
||||||
|
// identifiants croissent avec la création.
|
||||||
|
const enAttente = new Map();
|
||||||
|
|
||||||
|
const horodatage = () => {
|
||||||
|
const local = utc + ecoule + decalage * 60_000;
|
||||||
|
const jours = Math.floor(local / MS_PAR_JOUR);
|
||||||
|
const { annee, mois, jour } = dateCivile(jours);
|
||||||
|
const secondes = Math.floor((local - jours * MS_PAR_JOUR) / 1000);
|
||||||
|
const heure = `${deux(Math.floor(secondes / 3600))}:${deux(Math.floor(secondes / 60) % 60)}:${deux(secondes % 60)}`;
|
||||||
|
return `${String(annee).padStart(4, '0')}-${deux(mois)}-${deux(jour)}T${heure}${ecrireDecalage(decalage)}`;
|
||||||
|
};
|
||||||
|
const instant = () => ecoule;
|
||||||
|
const planifier = (fn, ms) => {
|
||||||
|
exigerMinuterie(fn, ms);
|
||||||
|
const id = prochainId;
|
||||||
|
prochainId += 1;
|
||||||
|
enAttente.set(id, { echeance: ecoule + ms, fn });
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
const annuler = (id) => {
|
||||||
|
enAttente.delete(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// La prochaine minuterie échue au plus tard à fin : la plus petite
|
||||||
|
// échéance, puis le plus petit identifiant ; null quand il n'y en a pas.
|
||||||
|
function prochaine(fin) {
|
||||||
|
let choisie = null;
|
||||||
|
for (const [id, { echeance }] of enAttente) {
|
||||||
|
if (echeance > fin) continue;
|
||||||
|
if (choisie === null || echeance < choisie.echeance || (echeance === choisie.echeance && id < choisie.id)) {
|
||||||
|
choisie = { id, echeance };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return choisie;
|
||||||
|
}
|
||||||
|
|
||||||
|
function avancer(ms) {
|
||||||
|
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) {
|
||||||
|
throw new RangeError(`horloge : avancer attend un délai fini et positif, reçu ${String(ms)}`);
|
||||||
|
}
|
||||||
|
const fin = ecoule + ms;
|
||||||
|
for (let suivante = prochaine(fin); suivante !== null; suivante = prochaine(fin)) {
|
||||||
|
const { fn } = enAttente.get(suivante.id);
|
||||||
|
enAttente.delete(suivante.id);
|
||||||
|
ecoule = suivante.echeance;
|
||||||
|
fn();
|
||||||
|
}
|
||||||
|
ecoule = fin;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { horodatage, instant, planifier, annuler, avancer };
|
||||||
|
}
|
||||||
187
src/application/horloge.test.js
Normal file
187
src/application/horloge.test.js
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Épreuves de l'horloge (§ 19.4, § 14.7, § 14.14) : la pilotée déclenche ses
|
||||||
|
// minuteries dans l'ordre de leur échéance, chacune à son instant, sans rien
|
||||||
|
// attendre du temps réel ; la système porte l'heure locale et son décalage, et
|
||||||
|
// aucune épreuve n'attend qu'une de ses minuteries parte. Les quatre lectures
|
||||||
|
// de chaque horloge répondent détachées de leur objet.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
|
import { creerHorlogePilotee, creerHorlogeSysteme } from './horloge.js';
|
||||||
|
|
||||||
|
const MINUTE = 60_000;
|
||||||
|
const FORME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})([+-])(\d{2}):(\d{2})$/;
|
||||||
|
|
||||||
|
describe('horloge pilotée : les minuteries (§ 19.4)', () => {
|
||||||
|
test("deux minuteries à 10 et 5 min : avancer(6 min) déclenche la seconde seule, avancer(4 min) la première", () => {
|
||||||
|
const horloge = creerHorlogePilotee();
|
||||||
|
const parties = [];
|
||||||
|
horloge.planifier(() => parties.push('dix'), 10 * MINUTE);
|
||||||
|
horloge.planifier(() => parties.push('cinq'), 5 * MINUTE);
|
||||||
|
assert.equal(horloge.avancer(6 * MINUTE), undefined);
|
||||||
|
assert.deepEqual(parties, ['cinq']);
|
||||||
|
horloge.avancer(4 * MINUTE);
|
||||||
|
assert.deepEqual(parties, ['cinq', 'dix']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une minuterie annulée ne part jamais', () => {
|
||||||
|
const horloge = creerHorlogePilotee();
|
||||||
|
const parties = [];
|
||||||
|
const id = horloge.planifier(() => parties.push('annulee'), MINUTE);
|
||||||
|
horloge.planifier(() => parties.push('gardee'), MINUTE);
|
||||||
|
horloge.annuler(id);
|
||||||
|
horloge.avancer(10 * MINUTE);
|
||||||
|
assert.deepEqual(parties, ['gardee']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un rappel lit horodatage() et instant() à son échéance, non à la fin de la fenêtre", () => {
|
||||||
|
const horloge = creerHorlogePilotee();
|
||||||
|
const lus = [];
|
||||||
|
horloge.planifier(() => lus.push([horloge.horodatage(), horloge.instant()]), 5 * MINUTE);
|
||||||
|
horloge.avancer(10 * MINUTE);
|
||||||
|
assert.deepEqual(lus, [['2026-10-06T19:05:00-04:00', 5 * MINUTE]]);
|
||||||
|
assert.equal(horloge.horodatage(), '2026-10-06T19:10:00-04:00');
|
||||||
|
assert.equal(horloge.instant(), 10 * MINUTE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une minuterie de 3 min qu'arme un rappel parti à 5 min part dans le même avancer(10 min), à 8 min", () => {
|
||||||
|
const horloge = creerHorlogePilotee();
|
||||||
|
const parties = [];
|
||||||
|
horloge.planifier(() => {
|
||||||
|
parties.push(['premiere', horloge.instant()]);
|
||||||
|
horloge.planifier(() => parties.push(['armee', horloge.instant()]), 3 * MINUTE);
|
||||||
|
}, 5 * MINUTE);
|
||||||
|
horloge.avancer(10 * MINUTE);
|
||||||
|
assert.deepEqual(parties, [
|
||||||
|
['premiere', 5 * MINUTE],
|
||||||
|
['armee', 8 * MINUTE],
|
||||||
|
]);
|
||||||
|
assert.equal(horloge.instant(), 10 * MINUTE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("à échéance égale, l'ordre de création ; une minuterie hors de la fenêtre attend l'avancer suivant", () => {
|
||||||
|
const horloge = creerHorlogePilotee();
|
||||||
|
const parties = [];
|
||||||
|
horloge.planifier(() => parties.push('b'), 2 * MINUTE);
|
||||||
|
horloge.planifier(() => parties.push('a1'), MINUTE);
|
||||||
|
horloge.planifier(() => parties.push('a2'), MINUTE);
|
||||||
|
horloge.planifier(() => parties.push('c'), 3 * MINUTE);
|
||||||
|
horloge.avancer(2 * MINUTE);
|
||||||
|
assert.deepEqual(parties, ['a1', 'a2', 'b']);
|
||||||
|
horloge.avancer(MINUTE - 1);
|
||||||
|
assert.deepEqual(parties, ['a1', 'a2', 'b']);
|
||||||
|
horloge.avancer(1);
|
||||||
|
assert.deepEqual(parties, ['a1', 'a2', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('avancer rend undefined ; un ms négatif ou non fini lève', () => {
|
||||||
|
const horloge = creerHorlogePilotee();
|
||||||
|
assert.equal(horloge.avancer(0), undefined);
|
||||||
|
for (const ms of [-1, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) {
|
||||||
|
assert.throws(() => horloge.avancer(ms), RangeError, String(ms));
|
||||||
|
}
|
||||||
|
assert.equal(horloge.instant(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("horodatage avance d'autant, décalage compris, par-delà minuit, la fin du mois et de l'année", () => {
|
||||||
|
const horloge = creerHorlogePilotee();
|
||||||
|
assert.equal(horloge.horodatage(), '2026-10-06T19:00:00-04:00');
|
||||||
|
horloge.avancer(90 * MINUTE + 7_000);
|
||||||
|
assert.equal(horloge.horodatage(), '2026-10-06T20:30:07-04:00');
|
||||||
|
horloge.avancer(4 * 60 * MINUTE);
|
||||||
|
assert.equal(horloge.horodatage(), '2026-10-07T00:30:07-04:00');
|
||||||
|
const fin = creerHorlogePilotee({ depart: '2028-12-31T23:59:30+05:30' });
|
||||||
|
fin.avancer(45_000);
|
||||||
|
assert.equal(fin.horodatage(), '2029-01-01T00:00:15+05:30');
|
||||||
|
const bissextile = creerHorlogePilotee({ depart: '2028-02-28T12:00:00+00:00' });
|
||||||
|
bissextile.avancer(24 * 60 * MINUTE);
|
||||||
|
assert.equal(bissextile.horodatage(), '2028-02-29T12:00:00+00:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('instant est monotone, et commence à zéro', () => {
|
||||||
|
const horloge = creerHorlogePilotee();
|
||||||
|
const lus = [horloge.instant()];
|
||||||
|
for (const pas of [0, 1, 250, 0, 60_000]) {
|
||||||
|
horloge.avancer(pas);
|
||||||
|
lus.push(horloge.instant());
|
||||||
|
}
|
||||||
|
assert.equal(lus[0], 0);
|
||||||
|
for (let i = 1; i < lus.length; i += 1) assert.ok(lus[i] >= lus[i - 1], `${lus[i - 1]} puis ${lus[i]}`);
|
||||||
|
assert.equal(lus.at(-1), 60_251);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rien n'attend le temps réel : un avancer d'une semaine se rend sur-le-champ", () => {
|
||||||
|
const horloge = creerHorlogePilotee();
|
||||||
|
let parties = 0;
|
||||||
|
for (let i = 1; i <= 100; i += 1) horloge.planifier(() => (parties += 1), i * 60 * MINUTE);
|
||||||
|
horloge.avancer(7 * 24 * 60 * MINUTE);
|
||||||
|
assert.equal(parties, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un départ hors de la forme AAAA-MM-JJTHH:MM:SS±HH:MM lève', () => {
|
||||||
|
for (const depart of ['2026-10-06 19:00:00', '2026-13-01T00:00:00+00:00', '2026-02-30T00:00:00+00:00', 'demain']) {
|
||||||
|
assert.throws(() => creerHorlogePilotee({ depart }), RangeError, depart);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('planifier refuse un délai négatif ou non fini, et un rappel qui ne se lance pas', () => {
|
||||||
|
const horloge = creerHorlogePilotee();
|
||||||
|
assert.throws(() => horloge.planifier(() => {}, -5), RangeError);
|
||||||
|
assert.throws(() => horloge.planifier(() => {}, Number.NaN), RangeError);
|
||||||
|
assert.throws(() => horloge.planifier('rappel', 5), TypeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('horloge système (§ 19.4, § 14.14)', () => {
|
||||||
|
test("horodatage a la forme AAAA-MM-JJTHH:MM:SS±HH:MM, l'heure courante, et porte le décalage local", () => {
|
||||||
|
const horloge = creerHorlogeSysteme();
|
||||||
|
const lu = horloge.horodatage();
|
||||||
|
const morceaux = FORME.exec(lu);
|
||||||
|
assert.ok(morceaux !== null, lu);
|
||||||
|
const instant = Date.parse(lu);
|
||||||
|
assert.ok(Math.abs(instant - Date.now()) < 5_000, `${lu} loin de l'heure courante`);
|
||||||
|
const [, , , , , , , signe, heures, minutes] = morceaux;
|
||||||
|
const decalage = (signe === '-' ? -1 : 1) * (Number(heures) * 60 + Number(minutes));
|
||||||
|
assert.equal(decalage, -new Date(instant).getTimezoneOffset());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("instant est monotone ; planifier rend un identifiant qu'annuler accepte, sans qu'aucune épreuve n'attende qu'il parte", () => {
|
||||||
|
const horloge = creerHorlogeSysteme();
|
||||||
|
const avant = horloge.instant();
|
||||||
|
assert.ok(horloge.instant() >= avant);
|
||||||
|
let partie = false;
|
||||||
|
const id = horloge.planifier(() => (partie = true), 60 * MINUTE);
|
||||||
|
assert.notEqual(id, undefined);
|
||||||
|
assert.equal(horloge.annuler(id), undefined);
|
||||||
|
assert.equal(horloge.annuler(id), undefined, 'un identifiant déjà annulé est sans effet');
|
||||||
|
assert.equal(horloge.annuler('inconnu'), undefined);
|
||||||
|
assert.equal(partie, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('horloges : quatre fermetures appelables détachées', () => {
|
||||||
|
test('la pilotée répond détachée comme sur son objet ; une minuterie annulée détachée ne part pas', () => {
|
||||||
|
const horloge = creerHorlogePilotee();
|
||||||
|
const { horodatage, instant, planifier, annuler } = horloge;
|
||||||
|
const parties = [];
|
||||||
|
const id = planifier(() => parties.push('annulee'), MINUTE);
|
||||||
|
planifier(() => parties.push('gardee'), MINUTE);
|
||||||
|
annuler(id);
|
||||||
|
horloge.avancer(2 * MINUTE);
|
||||||
|
assert.deepEqual(parties, ['gardee']);
|
||||||
|
assert.equal(instant(), horloge.instant());
|
||||||
|
assert.equal(horodatage(), horloge.horodatage());
|
||||||
|
assert.equal(horodatage(), '2026-10-06T19:02:00-04:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la système répond détachée : armer puis annuler', () => {
|
||||||
|
const { horodatage, instant, planifier, annuler } = creerHorlogeSysteme();
|
||||||
|
assert.match(horodatage(), FORME);
|
||||||
|
assert.equal(typeof instant(), 'number');
|
||||||
|
const id = planifier(() => {
|
||||||
|
throw new Error('une minuterie annulée est partie');
|
||||||
|
}, 60 * MINUTE);
|
||||||
|
assert.equal(annuler(id), undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,657 +1,70 @@
|
||||||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
// La table des textes de l'application (§ 14.6) : chaque code que lèvent ou
|
// L'assemblage des textes de l'application (§ 14.6) : chaque code que lèvent
|
||||||
// rendent src/stockage, src/csv et src/application — refus, avertissements,
|
// ou rendent src/stockage, src/csv et src/application — refus, avertissements,
|
||||||
// raisons, causes et remèdes de leurs détails — et chaque libellé figé d'une
|
// raisons, causes et remèdes de leurs détails —, chaque libellé figé d'une
|
||||||
// entrée ou d'un jalon du journal a ici son texte français, qu'une fonction
|
// entrée ou d'un jalon du journal, et les textes de la mise en forme ont leur
|
||||||
// compose de ses détails. Un code sans texte lève : l'écran ne montre jamais
|
// texte français dans l'une des tables de libelles/, une par domaine. Ce
|
||||||
// un code brut. Une valeur codée d'un détail — état, champ, mode, échec —
|
// module les réunit par le mécanisme de traduction (traduction.js), dans
|
||||||
// que la table ne connaît pas lève aussi, au lieu de se taire. Une raison
|
// l'ordre alphabétique de leurs fichiers ; aucune table ne l'importe.
|
||||||
// qu'un texte ne traite pas à part ne lève jamais : une phrase générique la
|
//
|
||||||
// nomme, par son texte quand la table en porte un, telle quelle sinon. Une
|
// Un code sans texte lève : l'écran ne montre jamais un code brut. Une valeur
|
||||||
// cause que nomme le système, EROFS, ENOENT ou TypeMismatchError, n'est pas
|
// codée d'un détail — état, champ, mode, échec — que la table ne connaît pas
|
||||||
// un code de l'application : les textes la citent telle quelle, entre
|
// lève aussi, au lieu de se taire.
|
||||||
// parenthèses ; une cause que la table porte s'y lit par son texte.
|
|
||||||
//
|
//
|
||||||
// Un libellé d'entrée se compose une fois, à l'écriture du geste, et le
|
// Un libellé d'entrée se compose une fois, à l'écriture du geste, et le
|
||||||
// journal en garde le texte, jamais le code (§ 8.2) : changer un texte ici ne
|
// journal en garde le texte, jamais le code (§ 8.2) : changer un texte ici ne
|
||||||
// change aucun libellé déjà écrit.
|
// change aucun libellé déjà écrit. libelle compose toujours en français : un
|
||||||
//
|
// texte qui devient une donnée — libellé figé, motif du CSV des refus — ne
|
||||||
// Les textes ne lisent ni l'horloge ni la langue du système : un horodatage
|
// passe jamais par la langue témoin.
|
||||||
// se met en mots à partir de sa seule forme, AAAA-MM-JJTHH:MM:SS±HH:MM.
|
import { TABLE as CSV } from './libelles/csv.js';
|
||||||
|
import { TABLE as EVENEMENTS } from './libelles/evenements.js';
|
||||||
|
import { TABLE as FORMAT } from './libelles/format.js';
|
||||||
|
import { TABLE as GENERATION } from './libelles/generation.js';
|
||||||
|
import { TABLE as MOBILIER } from './libelles/mobilier.js';
|
||||||
|
import { TABLE as MOTEUR } from './libelles/moteur.js';
|
||||||
|
import { TABLE as PARTICIPANTS } from './libelles/participants.js';
|
||||||
|
import { TABLE as PLACES } from './libelles/places.js';
|
||||||
|
import { TABLE as SEANCE } from './libelles/seance.js';
|
||||||
|
import { TABLE as STOCKAGE } from './libelles/stockage.js';
|
||||||
|
import { creerTraduction } from './traduction.js';
|
||||||
|
|
||||||
// Deux chaînes, comparées unité UTF-16 par unité.
|
/** Les tables de libelles/, dans l'ordre alphabétique de leurs fichiers. */
|
||||||
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
export const TABLES_APPLICATION = Object.freeze([
|
||||||
|
CSV,
|
||||||
const NOMS_DES_ETATS = new Map([
|
EVENEMENTS,
|
||||||
['brouillon', 'Brouillon'],
|
FORMAT,
|
||||||
['propose', 'Proposé'],
|
GENERATION,
|
||||||
['retenu', 'Retenu'],
|
MOBILIER,
|
||||||
['bloque', 'Bloqué'],
|
MOTEUR,
|
||||||
|
PARTICIPANTS,
|
||||||
|
PLACES,
|
||||||
|
SEANCE,
|
||||||
|
STOCKAGE,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Les champs d'un participant qu'une saisie modifie, dans l'ordre du fichier.
|
const FRANCAIS = creerTraduction(TABLES_APPLICATION);
|
||||||
const NOMS_DES_CHAMPS = new Map([
|
|
||||||
['nom', 'nom'],
|
|
||||||
['prenom', 'prénom'],
|
|
||||||
['appartenance', 'appartenance'],
|
|
||||||
['courriel', 'courriel'],
|
|
||||||
['titrePressenti', 'titre pressenti'],
|
|
||||||
['notes', 'notes'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
const MOIS = [
|
// Un code : des capitales, des chiffres, des soulignés entre deux mots. Un
|
||||||
'janvier',
|
// libellé, préfixé du nom de sa table, porte au moins un point.
|
||||||
'février',
|
const FORME_CODE = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/;
|
||||||
'mars',
|
|
||||||
'avril',
|
|
||||||
'mai',
|
|
||||||
'juin',
|
|
||||||
'juillet',
|
|
||||||
'août',
|
|
||||||
'septembre',
|
|
||||||
'octobre',
|
|
||||||
'novembre',
|
|
||||||
'décembre',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Valeur d'une table de noms, ou RangeError qui la nomme.
|
/** Les clés de forme code, seules, triées par unités UTF-16 croissantes. */
|
||||||
function nomDans(table, valeur, quoi) {
|
export const CODES = Object.freeze(FRANCAIS.cles().filter((cle) => FORME_CODE.test(cle)));
|
||||||
if (!table.has(valeur)) throw new RangeError(`libelle : ${quoi} inconnu ${JSON.stringify(valeur)}`);
|
|
||||||
return table.get(valeur);
|
|
||||||
}
|
|
||||||
const nomEtat = (etat) => nomDans(NOMS_DES_ETATS, etat, 'état');
|
|
||||||
const nomChamp = (champ) => nomDans(NOMS_DES_CHAMPS, champ, 'champ');
|
|
||||||
|
|
||||||
// Un texte cité entre guillemets français.
|
|
||||||
const cite = (texte) => `« ${texte} »`;
|
|
||||||
// Une cause entre parenthèses, rien quand elle manque : son texte quand la
|
|
||||||
// table en porte un, sinon la cause telle que le système la nomme.
|
|
||||||
function causeEntreParentheses(cause) {
|
|
||||||
if (cause === null || cause === undefined || cause === '') return '';
|
|
||||||
return ` (${TEXTES.has(cause) ? texteDe(cause) : cause})`;
|
|
||||||
}
|
|
||||||
// Le chemin de l'élément fautif, rien quand il manque ou désigne la racine.
|
|
||||||
const element = (chemin) => (typeof chemin === 'string' && chemin !== '' ? ` (${chemin})` : '');
|
|
||||||
// Un compte et son nom, au singulier jusqu'à un, comme le veut le français.
|
|
||||||
const compte = (n, singulier, pluriel) => `${n} ${n <= 1 ? singulier : pluriel}`;
|
|
||||||
// Une énumération : « a », « a et b », « a, b et c ».
|
|
||||||
function enumeration(termes) {
|
|
||||||
if (termes.length <= 1) return termes.join('');
|
|
||||||
return `${termes.slice(0, -1).join(', ')} et ${termes.at(-1)}`;
|
|
||||||
}
|
|
||||||
// Des numéros de proposition, un illisible nommé comme tel.
|
|
||||||
const numeros = (ids) => enumeration(ids.map((id) => (id === null ? 'sans numéro lisible' : `n° ${id}`)));
|
|
||||||
// La première lettre en capitale.
|
|
||||||
const capitale = (texte) => texte.charAt(0).toUpperCase() + texte.slice(1);
|
|
||||||
|
|
||||||
// Un horodatage AAAA-MM-JJTHH:MM:SS±HH:MM en mots, « le 17 mai 2031 à 8 h 05 »,
|
|
||||||
// à l'heure qu'il porte ; une autre forme se cite telle quelle.
|
|
||||||
const HORODATAGE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):\d{2}[+-]\d{2}:\d{2}$/;
|
|
||||||
function moment(horodatage) {
|
|
||||||
const morceaux = HORODATAGE.exec(String(horodatage));
|
|
||||||
if (morceaux === null) return String(horodatage);
|
|
||||||
const [annee, mois, jour, heure, minute] = morceaux.slice(1).map(Number);
|
|
||||||
if (mois < 1 || mois > 12) return String(horodatage);
|
|
||||||
const quantieme = jour === 1 ? '1er' : String(jour);
|
|
||||||
return `le ${quantieme} ${MOIS[mois - 1]} ${annee} à ${heure} h ${String(minute).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
const depuisQuand = (depuis) => (depuis === null || depuis === undefined ? '' : ` depuis ${moment(depuis)}`);
|
|
||||||
|
|
||||||
// Le chemin affichable d'une racine, reçue entière ou par son seul chemin.
|
|
||||||
const cheminDe = (racine) => (typeof racine === 'string' ? racine : racine?.chemin);
|
|
||||||
|
|
||||||
// Le remède commun aux refus d'encodage (§ 10.1).
|
|
||||||
const REMEDE_UTF8 = 'Réenregistrez le fichier en UTF-8 depuis le tableur, puis importez-le de nouveau.';
|
|
||||||
const refusEncodage = (constat) => () => `${constat} ${REMEDE_UTF8}`;
|
|
||||||
|
|
||||||
// Une raison codée en une phrase : son texte quand la table en porte un et
|
|
||||||
// que les détails reçus lui suffisent ; sinon une phrase générique qui la
|
|
||||||
// cite telle quelle. Un texte qui attend ses propres détails lève ou rend
|
|
||||||
// « undefined » sans eux : la phrase générique le remplace alors.
|
|
||||||
function phraseDeRaison(raison, details = {}) {
|
|
||||||
const generique = `La raison donnée est ${cite(String(raison))}.`;
|
|
||||||
if (!TEXTES.has(raison)) return generique;
|
|
||||||
try {
|
|
||||||
const texte = texteDe(raison, details);
|
|
||||||
return typeof texte === 'string' && !texte.includes('undefined') ? texte : generique;
|
|
||||||
} catch {
|
|
||||||
return generique;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// La raison d'un fichier d'état illisible, en une phrase.
|
|
||||||
function raisonIllisible({ raison, ...details }) {
|
|
||||||
if (raison === 'ABSENT') return "Le fichier d'état manque, à côté de son historique.";
|
|
||||||
return phraseDeRaison(raison, details);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Les secours d'un fichier d'état illisible, quand l'erreur les nomme.
|
|
||||||
function secoursDe(secours) {
|
|
||||||
if (secours === undefined || secours === null) return '';
|
|
||||||
const offerts = [];
|
|
||||||
if (secours.precedent !== null) offerts.push(`la version précédente du fichier (révision ${secours.precedent})`);
|
|
||||||
if (secours.journal !== null) offerts.push(`le dernier instant de l'historique (révision ${secours.journal})`);
|
|
||||||
if (offerts.length === 0) return ' Aucune version de secours ne se lit.';
|
|
||||||
return ` ${capitale(enumeration(offerts))} ${offerts.length === 1 ? 'peut' : 'peuvent'} se rouvrir.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ce qu'une dérive touche, du retenu aux propositions, et l'accord du verbe.
|
|
||||||
function touchees({ ids, retenu }) {
|
|
||||||
const parties = [];
|
|
||||||
if (retenu) parties.push('le placement retenu');
|
|
||||||
if (ids.length === 1) parties.push(`la proposition n° ${ids[0]}`);
|
|
||||||
if (ids.length > 1) parties.push(`les propositions n° ${enumeration(ids.map(String))}`);
|
|
||||||
return { texte: capitale(enumeration(parties)), pluriel: (retenu ? 1 : 0) + ids.length > 1 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Les titres de place qu'un geste sur une personne touche, quand il y en a.
|
|
||||||
const titresDe = (n, singulier, pluriel) =>
|
|
||||||
n > 0 ? `, et ${compte(n, 'titre de place', 'titres de place')} ${n <= 1 ? singulier : pluriel}` : '';
|
|
||||||
|
|
||||||
// Ce qu'un refus d'écriture nomme : le fichier, son dossier, la cause.
|
|
||||||
const ouEcrire = ({ chemin, dossier, cause }) =>
|
|
||||||
`${cite(chemin)} dans le dossier ${cite(dossier)}${causeEntreParentheses(cause)}`;
|
|
||||||
|
|
||||||
const TEXTES = new Map([
|
|
||||||
// --- Le système de fichiers et le dépôt (src/stockage/types.js) ---------
|
|
||||||
[
|
|
||||||
'ABSENT',
|
|
||||||
({ chemin }) =>
|
|
||||||
typeof chemin === 'string' ? `Le fichier ${cite(chemin)} est introuvable.` : "Le fichier d'état manque.",
|
|
||||||
],
|
|
||||||
['ECRITURE', (details) => `L'écriture de ${ouEcrire(details)} a échoué ; le fichier d'avant reste intact.`],
|
|
||||||
['LECTURE', (details) => `La lecture de ${ouEcrire(details)} est refusée.`],
|
|
||||||
['EXISTE', ({ chemin }) => `Un fichier ${cite(chemin)} existe déjà.`],
|
|
||||||
['CHEMIN_REFUSE', ({ chemin }) => `Le chemin ${cite(String(chemin))} est refusé.`],
|
|
||||||
[
|
|
||||||
'ETAT_ILLISIBLE',
|
|
||||||
(details) => {
|
|
||||||
const sujet = typeof details.base === 'string' ? `Le fichier d'état de ${cite(details.base)}` : 'Le fichier';
|
|
||||||
return `${sujet} ne s'ouvre pas. ${raisonIllisible(details)}${secoursDe(details.secours)}`;
|
|
||||||
},
|
|
||||||
],
|
|
||||||
['CORRECTIF', ({ rang }) => `Un correctif de l'historique ne s'applique pas (opération ${rang}).`],
|
|
||||||
[
|
|
||||||
'CHEMIN_TROP_LONG',
|
|
||||||
({ racine }) =>
|
|
||||||
`Le dossier ${cite(cheminDe(racine))} est trop profond : aucun nom de fichier n'y tient ` +
|
|
||||||
'sous la longueur de chemin que Windows accepte.',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'IDENTIFIANT_PRESENT',
|
|
||||||
({ base }) =>
|
|
||||||
`Cet événement est déjà dans ce dossier, sous ${cite(base)} : ` +
|
|
||||||
"l'y placer une seconde fois en ferait deux copies de même identifiant.",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'VERROU_PRIS',
|
|
||||||
({ depuis, vivant }) => {
|
|
||||||
if (vivant === true) {
|
|
||||||
return `Une autre séance modifie cet événement${depuisQuand(depuis)} : il reste en lecture ici.`;
|
|
||||||
}
|
|
||||||
if (vivant === false) {
|
|
||||||
return (
|
|
||||||
`Une séance interrompue a laissé son verrou${depuisQuand(depuis)}, et il n'a pas pu se reprendre : ` +
|
|
||||||
"l'événement reste en lecture."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
`Un verrou dont la séance ne se laisse pas vérifier — autre poste, verrou illisible, ou séance de ce poste dont la vie ne se prouve pas — tient cet événement${depuisQuand(depuis)} : ` +
|
|
||||||
'il reste en lecture ici, et rien ne le reprend seul.'
|
|
||||||
);
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'LECTURE_SEULE',
|
|
||||||
({ raison }) => {
|
|
||||||
if (raison === 'BLOQUE') {
|
|
||||||
return `${texteDe('BLOQUE')} Rien ne peut changer ; « Débloquer » le rend de nouveau modifiable.`;
|
|
||||||
}
|
|
||||||
if (raison === 'FORMAT' || raison === 'FORMAT_PLUS_RECENT') {
|
|
||||||
return `${texteDe(raison)} Il s'ouvre en lecture seule, et aucun geste ne le modifie ici.`;
|
|
||||||
}
|
|
||||||
if (raison === 'ENCODAGE') {
|
|
||||||
return (
|
|
||||||
`Le fichier d'état a changé sur le disque. ${texteDe('ENCODAGE')} ` +
|
|
||||||
"L'événement reste en lecture seule ; rouvert par une version de secours, son premier geste " +
|
|
||||||
"range ce fichier dans la corbeille avant d'écrire l'état."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return `${phraseDeRaison(raison)} L'événement reste en lecture seule, et aucun geste ne le modifie ici.`;
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'ETAT_NON_ECRIT',
|
|
||||||
(details) => `Le geste est inscrit à l'historique, mais l'état ${ouEcrire(details)} n'a pas pu s'écrire.`,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'CORBEILLE_SATUREE',
|
|
||||||
({ dossier }) =>
|
|
||||||
`La corbeille ${cite(dossier)} a reçu trop de suppressions dans la même seconde : ` +
|
|
||||||
'la suppression se refait une seconde plus tard.',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'COMPTEUR_SATURE',
|
|
||||||
({ compteur }) =>
|
|
||||||
`Les numéros ${compteur === 'proposition' ? 'de proposition' : `du compteur ${cite(compteur)}`} sont épuisés : ` +
|
|
||||||
'rien de neuf ne se numérote plus dans cet événement.',
|
|
||||||
],
|
|
||||||
['NON_DISPONIBLE', ({ cause }) => `Cette commande n'est pas offerte ici${causeEntreParentheses(cause)}.`],
|
|
||||||
|
|
||||||
// --- Les raisons d'un fichier illisible ----------------------------------
|
|
||||||
['VIDE', () => 'Le fichier est vide.'],
|
|
||||||
['JSON', () => "Le texte n'est pas du JSON valide."],
|
|
||||||
['FORME', ({ chemin }) => `Une valeur sort de sa règle${element(chemin)}.`],
|
|
||||||
['COMPTES', ({ chemin }) => `Un compte de l'en-tête contredit sa liste${element(chemin)}.`],
|
|
||||||
['REFERENCE', ({ chemin }) => `Une réservation ou un titre désigne ce qui n'existe pas${element(chemin)}.`],
|
|
||||||
['FORMAT_INCONNU', () => "Le format du fichier n'est pas reconnu."],
|
|
||||||
[
|
|
||||||
'FORMAT_PLUS_RECENT',
|
|
||||||
({ format }) => {
|
|
||||||
const quel = Number.isInteger(format) ? ` (format ${format})` : '';
|
|
||||||
return `Ce fichier vient d'une version plus récente du logiciel${quel}.`;
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'ENCODAGE',
|
|
||||||
() => "Ses octets ne sont pas de l'UTF-8 : un éditeur l'a sans doute réenregistré dans un autre encodage.",
|
|
||||||
],
|
|
||||||
|
|
||||||
// --- Les fautes d'un placement, et ses raisons de dérive (§ 8.9, § 9) ----
|
|
||||||
[
|
|
||||||
'LONGUEUR',
|
|
||||||
({ tour, table, declare, ecrit }) => {
|
|
||||||
if (tour === null) return `Le placement déclare ${compte(declare, 'tour', 'tours')} et en porte ${ecrit}.`;
|
|
||||||
if (table === null) {
|
|
||||||
return `Au tour ${tour}, le placement déclare ${compte(declare, 'table', 'tables')} et en porte ${ecrit}.`;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
`Au tour ${tour}, la table ${table} reçoit ${compte(ecrit, 'personne', 'personnes')} ` +
|
|
||||||
`pour ${compte(declare, 'place', 'places')}.`
|
|
||||||
);
|
|
||||||
},
|
|
||||||
],
|
|
||||||
['DOUBLON', ({ tour, participant }) => `Au tour ${tour}, la personne n° ${participant} est placée plus d'une fois.`],
|
|
||||||
[
|
|
||||||
'INCONNU',
|
|
||||||
({ tour, participant }) => `Au tour ${tour}, la personne n° ${participant} est placée sans être déclarée.`,
|
|
||||||
],
|
|
||||||
['MANQUANT', ({ tour, participant }) => `Au tour ${tour}, la personne n° ${participant} n'est placée nulle part.`],
|
|
||||||
['IDENTIFIANT_REPETE', () => 'Une autre proposition porte déjà ce numéro.'],
|
|
||||||
['IDENTIFIANT_HORS_COMPTEUR', () => 'Ce numéro de proposition dépasse le compteur du fichier.'],
|
|
||||||
['PARTICIPANT_EXCLU', ({ participant }) => `La personne n° ${participant} est exclue depuis.`],
|
|
||||||
['PARTICIPANT_SUPPRIME', ({ participant }) => `La personne n° ${participant} a été supprimée depuis.`],
|
|
||||||
[
|
|
||||||
'PARTICIPANT_NON_PLACE',
|
|
||||||
({ participant }) => `La personne n° ${participant}, ajoutée ou réintégrée depuis, n'y est pas placée.`,
|
|
||||||
],
|
|
||||||
['TABLE_SUPPRIMEE', ({ table }) => `La table d'identifiant ${table} a été supprimée depuis.`],
|
|
||||||
['TABLE_AJOUTEE', ({ table }) => `La table d'identifiant ${table} a été ajoutée depuis.`],
|
|
||||||
[
|
|
||||||
'CAPACITE_CHANGEE',
|
|
||||||
({ table, avant, maintenant }) =>
|
|
||||||
`La table d'identifiant ${table} est passée de ${avant} à ${compte(maintenant, 'place', 'places')}.`,
|
|
||||||
],
|
|
||||||
['TOURS_CHANGES', ({ avant, maintenant }) => `Le nombre de tours est passé de ${avant} à ${maintenant}.`],
|
|
||||||
|
|
||||||
// --- Ce que l'ouverture annonce (§ 8.6, § 8.8, § 8.9, § 9) ---------------
|
|
||||||
['RETENU_FAUTIF', () => 'Le placement retenu se contredit : la version précédente du fichier peut le reprendre.'],
|
|
||||||
['JOURNAL_REPRIS', () => "Le dernier geste, inscrit à l'historique mais pas dans l'état, est repris."],
|
|
||||||
[
|
|
||||||
'JOURNAL_EN_AVANCE_DE',
|
|
||||||
({ n }) =>
|
|
||||||
`L'historique compte ${compte(n, 'geste', 'gestes')} de plus que l'état : ` +
|
|
||||||
"l'état du disque est affiché, et le prochain geste inscrit une reprise.",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'JOURNAL_INCOMPLET',
|
|
||||||
({ etat, journal }) =>
|
|
||||||
`L'historique s'arrête à la révision ${journal}, l'état est à la révision ${etat} : ` +
|
|
||||||
'le prochain geste inscrit une reprise.',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'JOURNAL_ECARTEES',
|
|
||||||
({ n }) =>
|
|
||||||
`${compte(n, 'entrée', 'entrées')} de l'historique ${n <= 1 ? 'est écartée' : 'sont écartées'}, ` +
|
|
||||||
'à partir de la première ligne illisible.',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'JOURNAL_RECREE',
|
|
||||||
() =>
|
|
||||||
"Le fichier de l'historique avait disparu du dossier : il est réécrit en entier, " +
|
|
||||||
'tel que la séance le connaissait.',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'JOURNAL_FORMAT_PLUS_RECENT',
|
|
||||||
({ format }) =>
|
|
||||||
`L'historique vient d'une version plus récente du logiciel (format ${format}) : ` +
|
|
||||||
"il ne se lit pas, et l'événement reste en lecture seule.",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'JOURNAL_ILLISIBLE',
|
|
||||||
({ raison }) =>
|
|
||||||
`L'historique à côté de ce fichier ne se lit pas. ${phraseDeRaison(raison)} ` +
|
|
||||||
"Il est laissé tel quel, et le prochain geste commence l'historique dans un nouveau fichier.",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'JOURNAL_ETRANGER',
|
|
||||||
({ evenement }) =>
|
|
||||||
`L'historique à côté de ce fichier appartient à un autre événement (${evenement}) : il est ignoré.`,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'PROPOSITIONS_FAUTIVES',
|
|
||||||
({ ids }) =>
|
|
||||||
ids.length <= 1
|
|
||||||
? `Une proposition incohérente est écartée (${numeros(ids)}).`
|
|
||||||
: `${ids.length} propositions incohérentes sont écartées (${numeros(ids)}).`,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'PROPOSITIONS_MANQUANTES',
|
|
||||||
({ n }) =>
|
|
||||||
`${compte(n, "proposition que l'en-tête annonce manque", "propositions que l'en-tête annonce manquent")} ` +
|
|
||||||
'dans le fichier : celles qu\'il porte sont gardées.',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'DERIVE',
|
|
||||||
(details) => {
|
|
||||||
const { texte, pluriel } = touchees(details);
|
|
||||||
return pluriel
|
|
||||||
? `${texte} ne décrivent plus la liste ou les tables actuelles : ils sont gardés, et rien n'est effacé.`
|
|
||||||
: `${texte} ne décrit plus la liste ou les tables actuelles : il est gardé, et rien n'est effacé.`;
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
// --- Le dossier de travail, les réglages locaux (§ 8.5, § 8.6, § 8.8) ----
|
|
||||||
['NON_PUBLIE', () => "Le dossier de l'exécutable n'est pas connu : les événements se rangent dans les Documents."],
|
|
||||||
[
|
|
||||||
'DONNEES_APPLICATIVES',
|
|
||||||
() => "L'exécutable est rangé sous les données applicatives : les événements se rangent dans les Documents.",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'SONDE_ECHOUEE',
|
|
||||||
({ cause }) =>
|
|
||||||
`Le dossier data à côté de l'exécutable refuse l'écriture${causeEntreParentheses(cause)} : ` +
|
|
||||||
'les événements se rangent dans les Documents.',
|
|
||||||
],
|
|
||||||
// Une cause de la sonde, que les textes citent entre parenthèses.
|
|
||||||
['RELECTURE_DIFFERENTE', () => "le fichier d'essai relu ne porte pas ce qui vient d'y être écrit"],
|
|
||||||
[
|
|
||||||
'PORTABLE_NON_INSCRIPTIBLE',
|
|
||||||
({ racine, evenements, cause }) => {
|
|
||||||
const porte =
|
|
||||||
evenements === null
|
|
||||||
? 'porte peut-être des événements'
|
|
||||||
: `porte ${compte(evenements, 'événement', 'événements')}`;
|
|
||||||
return `Le dossier ${cite(cheminDe(racine))} ${porte}, mais refuse l'écriture${causeEntreParentheses(cause)}.`;
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'DOCUMENTS_NON_INSCRIPTIBLE',
|
|
||||||
({ racine, cause }) =>
|
|
||||||
`Le dossier de travail ${cite(cheminDe(racine))} refuse l'écriture${causeEntreParentheses(cause)} : ` +
|
|
||||||
"aucun geste ne s'y enregistrerait.",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'PLATEFORME_WEB',
|
|
||||||
() =>
|
|
||||||
"Sous la plateforme web, ni le renommage atomique ni le verrou n'existent : " +
|
|
||||||
'une panne peut laisser un fichier incomplet, et deux onglets peuvent écrire le même événement.',
|
|
||||||
],
|
|
||||||
// dossier, quand il est donné, nomme le dossier où la séance écrit
|
|
||||||
// désormais (ecrireAilleurs) ; sans lui, le dossier de travail.
|
|
||||||
[
|
|
||||||
'SUPPORT_AMOVIBLE',
|
|
||||||
({ dossier }) =>
|
|
||||||
`${typeof dossier === 'string' ? `Le dossier ${cite(dossier)}` : 'Le dossier de travail'} ` +
|
|
||||||
'est sur un support amovible : retirer le support pendant une écriture peut la laisser incomplète.',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'REGLAGES_ILLISIBLES',
|
|
||||||
({ chemin, raison }) => {
|
|
||||||
const pourquoi =
|
|
||||||
raison === 'JSON' || raison === 'FORME' ? texteDe(raison) : `La lecture est refusée (${raison}).`;
|
|
||||||
return `Les réglages locaux ${cite(chemin)} ne se lisent pas. ${pourquoi} Les valeurs par défaut s'appliquent.`;
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
// --- Le CSV (§ 10.1) -------------------------------------------------------
|
|
||||||
['UTF16_SANS_MARQUE', refusEncodage("Le fichier semble encodé en UTF-16, sans la marque qui l'annonce.")],
|
|
||||||
['UTF16_INVALIDE', refusEncodage("Le fichier s'annonce en UTF-16, mais ses octets ne le sont pas.")],
|
|
||||||
['UTF8_INVALIDE', refusEncodage("Le fichier s'annonce en UTF-8, mais ses octets ne le sont pas.")],
|
|
||||||
['CARACTERE_NUL', refusEncodage("Le texte porte un caractère nul, qu'aucune liste ne contient.")],
|
|
||||||
['ENREGISTRER_EN_UTF8', () => REMEDE_UTF8],
|
|
||||||
[
|
|
||||||
'SEPARATEUR_INTROUVABLE',
|
|
||||||
({ ligne, cause }) => {
|
|
||||||
const constat = 'Aucun séparateur ne convient : ni le point-virgule, ni la virgule, ni la tabulation.';
|
|
||||||
if (ligne === null || ligne === undefined) return constat;
|
|
||||||
const ecart = texteDe(cause === 'GUILLEMET_OUVERT' ? 'GUILLEMET_OUVERT' : 'NOMBRE_DE_CHAMPS', { ligne });
|
|
||||||
return `${constat} ${ecart}`;
|
|
||||||
},
|
|
||||||
],
|
|
||||||
['NOMBRE_DE_CHAMPS', ({ ligne }) => `Avec chacun, la ligne ${ligne} n'a pas le nombre de champs de l'en-tête.`],
|
|
||||||
[
|
|
||||||
'GUILLEMET_OUVERT',
|
|
||||||
({ ligne }) =>
|
|
||||||
`Un guillemet ouvert${Number.isInteger(ligne) ? ` à la ligne ${ligne}` : ''} ne se referme pas : ` +
|
|
||||||
'tout ce qui le suit tiendrait dans un seul champ.',
|
|
||||||
],
|
|
||||||
['AUCUN_ENTETE', () => "Aucun en-tête n'est reconnu : la première ligne nomme les colonnes, « nom » au moins."],
|
|
||||||
['NOM_NON_ASSOCIE', () => "Aucune colonne ne porte le nom : l'en-tête doit compter une colonne « nom »."],
|
|
||||||
['AUCUNE_LIGNE_VALIDE', () => "Aucune ligne n'est valide : rien n'est importé."],
|
|
||||||
['CHAMPS_EN_TROP', () => "La ligne compte plus de champs que l'en-tête."],
|
|
||||||
['NOM_ABSENT', () => 'Le nom manque.'],
|
|
||||||
[
|
|
||||||
'EXCLU_INCONNU',
|
|
||||||
({ valeur }) =>
|
|
||||||
`La valeur d'« exclu » ${cite(valeur)} n'est pas reconnue : ` +
|
|
||||||
'oui, o, vrai, 1 ou x pour exclure ; non, n, faux, 0 ou vide sinon.',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'PLUSIEURS_CORRESPONDENT',
|
|
||||||
({ participants }) =>
|
|
||||||
'Plusieurs personnes de la liste correspondent à cette ligne ' +
|
|
||||||
`(${enumeration(participants.map((id) => `n° ${id}`))}) : rien n'est fusionné.`,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'DEJA_DESIGNE',
|
|
||||||
({ premiereLigne }) => `La ligne ${premiereLigne} désigne déjà cette personne : rien n'est fusionné.`,
|
|
||||||
],
|
|
||||||
['PLAN_BLOQUE', () => 'Le plan est bloqué : « Débloquer » le rend de nouveau modifiable.'],
|
|
||||||
[
|
|
||||||
'AMBIGUITE',
|
|
||||||
({ champs }) =>
|
|
||||||
`Plusieurs colonnes répondent au même champ (${enumeration(champs)}) : choisissez celle qui s'importe.`,
|
|
||||||
],
|
|
||||||
|
|
||||||
// --- La séance (§ 8.3, § 8.4, § 8.5, § 8.6, § 8.8) -------------------------
|
|
||||||
['AUCUN_EVENEMENT', () => "Aucun événement n'est ouvert."],
|
|
||||||
['MODE_LECTURE', () => "L'événement est ouvert en lecture : « Modifier » permet de le changer."],
|
|
||||||
['FORMAT', (details) => texteDe('FORMAT_PLUS_RECENT', details)],
|
|
||||||
['BLOQUE', () => 'Le plan est bloqué.'],
|
|
||||||
// echec nomme ce que le support n'a pas reçu : la ligne d'un geste
|
|
||||||
// (ECRITURE), l'état d'un geste inscrit au journal (ETAT_NON_ECRIT), le
|
|
||||||
// jalon d'un geste enregistré (JALON_NON_ECRIT), ou le verrou d'un passage
|
|
||||||
// en écriture (VERROU_NON_ECRIT), qui ne perd rien.
|
|
||||||
[
|
|
||||||
'SUPPORT_ROMPU',
|
|
||||||
({ echec, ...details }) => {
|
|
||||||
const refus = `Le support ne prend plus les écritures : ${ouEcrire(details)} n'a pas pu s'écrire`;
|
|
||||||
const ailleurs = "« Écrire ailleurs » enregistre l'événement dans un autre dossier.";
|
|
||||||
if (echec === 'ECRITURE' || echec === 'ETAT_NON_ECRIT') {
|
|
||||||
const perte =
|
|
||||||
echec === 'ETAT_NON_ECRIT'
|
|
||||||
? "le dernier geste est inscrit à l'historique, mais pas dans l'état"
|
|
||||||
: "le dernier geste n'est pas enregistré";
|
|
||||||
return `${refus}, et ${perte}. Sur ce support, l'historique et l'état ne se correspondent plus ; ${ailleurs}`;
|
|
||||||
}
|
|
||||||
if (echec === 'JALON_NON_ECRIT') {
|
|
||||||
return (
|
|
||||||
`${refus}. Le dernier geste est enregistré, mais pas le jalon qui le nomme, ` +
|
|
||||||
`et aucun geste ne s'y enregistrerait plus ; ${ailleurs}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (echec === 'VERROU_NON_ECRIT') {
|
|
||||||
return (
|
|
||||||
`${refus}. L'événement y reste tel qu'il s'affiche, ` +
|
|
||||||
`mais aucun geste ne s'y enregistrerait ; ${ailleurs}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new RangeError(`libelle : échec du support inconnu ${JSON.stringify(echec)}`);
|
|
||||||
},
|
|
||||||
],
|
|
||||||
['SUPPORT_INTACT', () => "Le support prend encore les écritures : l'événement reste où il est."],
|
|
||||||
// Quitter l'événement d'un support rompu : la séance tient la seule copie
|
|
||||||
// que l'écriture ailleurs peut encore sauver ; ETAT_NON_ECRIT dit que son
|
|
||||||
// dernier geste n'est qu'à l'historique de ce support.
|
|
||||||
[
|
|
||||||
'SUPPORT_ROMPU_NON_SAUVE',
|
|
||||||
({ echec, dossier }) => {
|
|
||||||
const geste =
|
|
||||||
echec === 'ETAT_NON_ECRIT' ? ", et son dernier geste n'y est inscrit qu'à l'historique" : '';
|
|
||||||
return (
|
|
||||||
`Le support ${cite(dossier)} ne prend plus les écritures${geste} : ` +
|
|
||||||
"l'événement ouvert n'est sauvé dans aucun autre dossier. " +
|
|
||||||
"« Écrire ailleurs » l'enregistre d'abord ; le quitter maintenant l'abandonne à ce support."
|
|
||||||
);
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'EVENEMENT_CHANGE',
|
|
||||||
({ revision }) =>
|
|
||||||
`Une autre séance a modifié cet événement depuis son ouverture : il s'affiche tel qu'elle l'a laissé, ` +
|
|
||||||
`à la révision ${revision}, et les gestes suivants s'y ajoutent.`,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'VERROU_REPRIS',
|
|
||||||
({ depuis }) => `Le verrou qu'une séance interrompue de ce poste avait laissé${depuisQuand(depuis)} est repris.`,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'VERROU_NON_RENDU',
|
|
||||||
({ cause }) =>
|
|
||||||
`Le verrou de l'événement n'a pas pu être rendu${causeEntreParentheses(cause)} : ` +
|
|
||||||
'une autre séance de ce poste le reprendra une fois ce logiciel fermé.',
|
|
||||||
],
|
|
||||||
['RIEN_A_DEFAIRE', () => "Rien à défaire : l'historique commence à cet instant."],
|
|
||||||
['RIEN_A_REFAIRE', () => 'Rien à refaire : seul un retour se refait, tant que nul geste ne le suit.'],
|
|
||||||
['INSTANT_NON_RESTITUABLE', ({ revision }) => `L'historique ne restitue plus l'instant ${revision}.`],
|
|
||||||
['PLAN_NON_BLOQUE', () => "Le plan n'est pas bloqué : il n'y a rien à débloquer."],
|
|
||||||
[
|
|
||||||
'JALON_NON_ECRIT',
|
|
||||||
({ dossier, cause }) =>
|
|
||||||
`Le geste est enregistré, mais le jalon qui le nomme n'a pas pu s'écrire ` +
|
|
||||||
`dans le dossier ${cite(dossier)}${causeEntreParentheses(cause)}.`,
|
|
||||||
],
|
|
||||||
['SANS_EFFET', () => "Ce geste ne change rien : rien n'est enregistré."],
|
|
||||||
|
|
||||||
// --- Les refus et les avertissements des commandes (§ 4.4, § 9, § 10.2) ---
|
|
||||||
['NOM_REQUIS', () => 'Le nom est obligatoire.'],
|
|
||||||
['DATE_INVALIDE', ({ date }) => `La date ${cite(String(date))} n'est pas un jour du calendrier, écrit AAAA-MM-JJ.`],
|
|
||||||
['SIEGES_INVALIDES', () => 'Une table compte au moins deux sièges.'],
|
|
||||||
['TOURS_INVALIDES', () => 'La soirée compte au moins un tour.'],
|
|
||||||
['PARTICIPANT_INCONNU', ({ id }) => `La personne n° ${id} n'est pas dans cet événement.`],
|
|
||||||
['PROPOSITION_INCONNUE', ({ id }) => `La proposition n° ${id} n'est pas dans cet événement.`],
|
|
||||||
['AUCUNE_PROPOSITION', () => "Aucune proposition n'existe : l'état « Proposé » suit une génération."],
|
|
||||||
['AUCUN_RETENU', () => "Aucun placement n'est retenu : l'état « Retenu » suit le choix d'une proposition."],
|
|
||||||
[
|
|
||||||
'RESERVATIONS_SUSPENDUES',
|
|
||||||
({ personne, reservations, titres }) =>
|
|
||||||
`Exclusion de ${personne} : ${compte(reservations, 'réservation suspendue', 'réservations suspendues')}, ` +
|
|
||||||
`ses places reviennent aux autres${titresDe(titres, "n'est plus pourvu", 'ne sont plus pourvus')}.`,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'RESERVATIONS_RETABLIES',
|
|
||||||
({ personne, reservations, titres }) =>
|
|
||||||
`Réintégration de ${personne} : ${compte(reservations, 'réservation rétablie', 'réservations rétablies')}` +
|
|
||||||
`${titresDe(titres, 'de nouveau pourvu', 'de nouveau pourvus')}.`,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'RESERVATIONS_RETIREES',
|
|
||||||
({ personne, reservations, titres }) =>
|
|
||||||
`Suppression de ${personne} : ${compte(reservations, 'réservation retirée', 'réservations retirées')}` +
|
|
||||||
`${titresDe(titres, "n'est plus pourvu", 'ne sont plus pourvus')}.`,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'IMPORT_EN_RETENU',
|
|
||||||
() =>
|
|
||||||
"L'import change la liste après le choix d'un placement : le placement retenu est gardé tel quel, " +
|
|
||||||
"et rien n'est effacé ; à vous de juger s'il convient encore.",
|
|
||||||
],
|
|
||||||
|
|
||||||
// --- Les libellés figés du journal (§ 8.2, § 8.3) --------------------------
|
|
||||||
['ENTREE_CREATION', ({ nom }) => `Création de l'événement ${cite(nom)}`],
|
|
||||||
['JALON_CREATION', () => 'Création'],
|
|
||||||
['JALON_ETAT', ({ etat }) => `État ${cite(nomEtat(etat))}`],
|
|
||||||
['JALON_AVANT_GENERATION', () => 'Avant la génération'],
|
|
||||||
['ENTREE_REPRISE', () => "Reprise de l'état trouvé sur le disque"],
|
|
||||||
['ENTREE_DEFAIRE', ({ libelle: instant }) => `Défait : ${cite(instant)}`],
|
|
||||||
['ENTREE_REFAIRE', ({ libelle: instant }) => `Refait : ${cite(instant)}`],
|
|
||||||
['ENTREE_REVENIR', ({ libelle: instant }) => `Retour à ${cite(instant)}`],
|
|
||||||
['ENTREE_AJOUTER_PARTICIPANT', ({ personne }) => `Ajouté ${personne}`],
|
|
||||||
[
|
|
||||||
'ENTREE_MODIFIER_PARTICIPANT',
|
|
||||||
({ personne, champs, devenu }) => {
|
|
||||||
const nouveau = devenu === null || devenu === undefined ? '' : ` (désormais ${devenu})`;
|
|
||||||
return `Modifié ${personne} : ${champs.map(nomChamp).join(', ')}${nouveau}`;
|
|
||||||
},
|
|
||||||
],
|
|
||||||
['ENTREE_EXCLURE_PARTICIPANT', ({ personne }) => `Exclu ${personne}`],
|
|
||||||
['ENTREE_REINTEGRER_PARTICIPANT', ({ personne }) => `Réintégré ${personne}`],
|
|
||||||
['ENTREE_SUPPRIMER_PARTICIPANT', ({ personne }) => `Supprimé ${personne}`],
|
|
||||||
[
|
|
||||||
'ENTREE_IMPORTER_PARTICIPANTS',
|
|
||||||
({ mode, ajoutes, misAJour, retires }) => {
|
|
||||||
const ajoutesTexte = compte(ajoutes, 'ajouté', 'ajoutés');
|
|
||||||
if (mode === 'ajouter') return `Importé ${compte(ajoutes, 'participant', 'participants')}`;
|
|
||||||
if (mode === 'mettreAJour') {
|
|
||||||
return `Mis à jour la liste par import : ${ajoutesTexte}, ${compte(misAJour, 'modifié', 'modifiés')}`;
|
|
||||||
}
|
|
||||||
if (mode === 'remplacer') {
|
|
||||||
return `Remplacé la liste par import : ${compte(retires, 'retiré', 'retirés')}, ${ajoutesTexte}`;
|
|
||||||
}
|
|
||||||
throw new RangeError(`libelle : mode d'import inconnu ${JSON.stringify(mode)}`);
|
|
||||||
},
|
|
||||||
],
|
|
||||||
['ENTREE_CHANGER_ETAT', ({ etat }) => `Plan passé à l'état ${cite(nomEtat(etat))}`],
|
|
||||||
['ENTREE_DEBLOQUER', ({ etat }) => `Plan débloqué, état ${cite(nomEtat(etat))}`],
|
|
||||||
[
|
|
||||||
'ENTREE_ENREGISTRER_GENERATION',
|
|
||||||
({ n, premier, dernier }) =>
|
|
||||||
`Généré ${compte(n, 'proposition', 'propositions')} ` +
|
|
||||||
`(n° ${premier === dernier ? premier : `${premier} à ${dernier}`})`,
|
|
||||||
],
|
|
||||||
['ENTREE_RETENIR_PROPOSITION', ({ id }) => `Retenu la proposition n° ${id}`],
|
|
||||||
[
|
|
||||||
'ENTREE_EFFACER_PROPOSITIONS',
|
|
||||||
({ n, gardee }) =>
|
|
||||||
`Effacé ${compte(n, 'proposition', 'propositions')}` +
|
|
||||||
`${gardee === null || gardee === undefined ? '' : `, gardé la retenue (n° ${gardee})`}`,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Le texte d'un code de la table, ou RangeError qui le nomme.
|
|
||||||
function texteDe(code, details = {}) {
|
|
||||||
const texte = TEXTES.get(code);
|
|
||||||
if (texte === undefined) throw new RangeError(`libelle : code inconnu ${JSON.stringify(code)}`);
|
|
||||||
return texte(details ?? {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Les codes qui ont un texte, triés par unités UTF-16 croissantes. */
|
|
||||||
export const CODES = Object.freeze([...TEXTES.keys()].sort(comparerTextes));
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Le texte français d'un code et de ses détails (§ 14.6) : un refus, un
|
* Le texte français d'une clé et de ses détails (§ 14.6) : un refus, un
|
||||||
* avertissement, une raison, ou le libellé figé d'une entrée ou d'un jalon.
|
* avertissement, une raison, le libellé figé d'une entrée ou d'un jalon, ou
|
||||||
|
* un libellé de l'application.
|
||||||
*
|
*
|
||||||
* @param {string} code
|
* @param {string} cle
|
||||||
* @param {Object} [details]
|
* @param {Object} [details]
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
* @throws {RangeError} un code sans texte, ou une valeur codée de ses détails
|
* @throws {RangeError} une clé sans texte, ou une valeur codée de ses détails
|
||||||
* — état, champ, mode, échec — que la table ne connaît pas ; jamais
|
* — état, champ, mode, échec — que la table ne connaît pas ; jamais
|
||||||
* une raison, qu'une phrase générique nomme
|
* une raison, qu'une phrase générique nomme
|
||||||
*/
|
*/
|
||||||
export function libelle(code, details = {}) {
|
export function libelle(cle, details = {}) {
|
||||||
return texteDe(code, details);
|
return FRANCAIS.t(cle, details);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,20 +12,27 @@
|
||||||
// porte une. Chaque code relevé a son texte, chaque texte a un émetteur, et
|
// porte une. Chaque code relevé a son texte, chaque texte a un émetteur, et
|
||||||
// un balayage vide échoue. Chaque texte se compose ensuite de détails
|
// un balayage vide échoue. Chaque texte se compose ensuite de détails
|
||||||
// d'exemple. Les noms d'épreuve sont inventés.
|
// d'exemple. Les noms d'épreuve sont inventés.
|
||||||
|
//
|
||||||
|
// La table se partage en fichiers, un par domaine, sous libelles/ : chacun
|
||||||
|
// exporte TABLE, une Map, et libelles.js les assemble par ordre alphabétique
|
||||||
|
// des fichiers ; les tables, comme l'assemblage, sont hors du balayage.
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readdirSync, readFileSync } from 'node:fs';
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
import { join, relative } from 'node:path';
|
import { join, relative, sep } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { describe, test } from '../../test/lanceur.js';
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
import { CODES, libelle } from './libelles.js';
|
import { CODES, TABLES_APPLICATION, libelle } from './libelles.js';
|
||||||
|
|
||||||
const RACINE = fileURLToPath(new URL('../..', import.meta.url));
|
const RACINE = fileURLToPath(new URL('../..', import.meta.url));
|
||||||
|
|
||||||
// Les arbres balayés, et les fichiers qui portent une table de codes.
|
// Les arbres balayés, et les fichiers qui portent une table de codes.
|
||||||
const ARBRES = ['src/stockage', 'src/csv', 'src/application'];
|
const ARBRES = ['src/stockage', 'src/csv', 'src/application'];
|
||||||
const TABLES = ['src/stockage/types.js', 'src/csv/erreurs.js'];
|
const TABLES = ['src/stockage/types.js', 'src/csv/erreurs.js'];
|
||||||
// La table des textes elle-même n'émet rien : ses clés sont ce qu'on éprouve.
|
// L'assemblage et les tables des textes n'émettent rien : leurs clés sont ce
|
||||||
|
// qu'on éprouve, et un texte qui en compose un autre le nomme sans l'émettre.
|
||||||
const TABLE_DES_TEXTES = join('src', 'application', 'libelles.js');
|
const TABLE_DES_TEXTES = join('src', 'application', 'libelles.js');
|
||||||
|
const DOSSIER_DES_TABLES = join('src', 'application', 'libelles');
|
||||||
|
const horsDesTextes = (chemin) => chemin !== TABLE_DES_TEXTES && !chemin.startsWith(DOSSIER_DES_TABLES + sep);
|
||||||
|
|
||||||
// --- Le balayage ----------------------------------------------------------
|
// --- Le balayage ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -79,7 +86,7 @@ function codesDesTables(texte) {
|
||||||
function modulesDe(arbre) {
|
function modulesDe(arbre) {
|
||||||
return readdirSync(join(RACINE, arbre), { recursive: true })
|
return readdirSync(join(RACINE, arbre), { recursive: true })
|
||||||
.map((nom) => join(arbre, nom))
|
.map((nom) => join(arbre, nom))
|
||||||
.filter((chemin) => chemin.endsWith('.js') && !/\.test\.js$/.test(chemin) && chemin !== TABLE_DES_TEXTES)
|
.filter((chemin) => chemin.endsWith('.js') && !/\.test\.js$/.test(chemin) && horsDesTextes(chemin))
|
||||||
.sort();
|
.sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -158,6 +165,16 @@ describe('libellés : le balayage des codes (§ 14.6, § 14.2)', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("le balayage exclut l'assemblage et chaque table de libelles/, et garde les autres modules de l'application", () => {
|
||||||
|
const tables = readdirSync(join(RACINE, DOSSIER_DES_TABLES)).filter((nom) => nom.endsWith('.js'));
|
||||||
|
assert.ok(tables.length >= 10, `${DOSSIER_DES_TABLES} : ${tables.length} modules`);
|
||||||
|
const balayes = modulesDe('src/application');
|
||||||
|
assert.ok(balayes.length > 0, 'src/application : aucun module balayé');
|
||||||
|
const dossier = join('src', 'application', 'libelles');
|
||||||
|
assert.deepEqual(balayes.filter((chemin) => chemin === `${dossier}.js` || chemin.startsWith(dossier + sep)), []);
|
||||||
|
for (const garde of ['commandes.js', 'seance.js']) assert.ok(balayes.includes(join('src', 'application', garde)), garde);
|
||||||
|
});
|
||||||
|
|
||||||
test('chaque arbre émet des codes, et la table de contrat du stockage en porte', () => {
|
test('chaque arbre émet des codes, et la table de contrat du stockage en porte', () => {
|
||||||
const { parArbre, tables } = releverArbre();
|
const { parArbre, tables } = releverArbre();
|
||||||
for (const arbre of ARBRES) assert.ok(parArbre.get(arbre).size > 0, `${arbre} : balayage vide`);
|
for (const arbre of ARBRES) assert.ok(parArbre.get(arbre).size > 0, `${arbre} : balayage vide`);
|
||||||
|
|
@ -249,6 +266,7 @@ const EXEMPLES = new Map([
|
||||||
['DONNEES_APPLICATIVES', {}],
|
['DONNEES_APPLICATIVES', {}],
|
||||||
['SONDE_ECHOUEE', { cause: 'EROFS' }],
|
['SONDE_ECHOUEE', { cause: 'EROFS' }],
|
||||||
['RELECTURE_DIFFERENTE', {}],
|
['RELECTURE_DIFFERENTE', {}],
|
||||||
|
['NOM_NON_CONVERTI', {}],
|
||||||
['PORTABLE_NON_INSCRIPTIBLE', { racine: { id: 'portable', chemin: 'E:\\data' }, evenements: 3, cause: 'EROFS' }],
|
['PORTABLE_NON_INSCRIPTIBLE', { racine: { id: 'portable', chemin: 'E:\\data' }, evenements: 3, cause: 'EROFS' }],
|
||||||
['DOCUMENTS_NON_INSCRIPTIBLE', { racine: { id: 'documents', chemin: 'C:\\Documents\\Table' }, evenements: 0, cause: 'EACCES' }],
|
['DOCUMENTS_NON_INSCRIPTIBLE', { racine: { id: 'documents', chemin: 'C:\\Documents\\Table' }, evenements: 0, cause: 'EACCES' }],
|
||||||
['PLATEFORME_WEB', {}],
|
['PLATEFORME_WEB', {}],
|
||||||
|
|
@ -525,3 +543,70 @@ describe('libellés : les textes (§ 14.6, § 8.2)', () => {
|
||||||
assert.match(libelle('PROPOSITIONS_FAUTIVES', { ids: [3, null] }), /n° 3/);
|
assert.match(libelle('PROPOSITIONS_FAUTIVES', { ids: [3, null] }), /n° 3/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Les tables par domaine ---------------------------------------------------
|
||||||
|
|
||||||
|
// Les deux formes de clé du contrat : un code, nu ; un libellé, préfixé du nom
|
||||||
|
// de sa table, en camelCase.
|
||||||
|
const FORME_CODE = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/;
|
||||||
|
const formeLibelle = (table) => new RegExp(`^${table}(?:\\.[a-z][A-Za-z0-9]*)+$`);
|
||||||
|
|
||||||
|
// Les modules de table du dossier, aides et épreuves exceptées, par ordre
|
||||||
|
// alphabétique, et ce que chacun exporte.
|
||||||
|
async function tablesDuDossier() {
|
||||||
|
const noms = readdirSync(join(RACINE, DOSSIER_DES_TABLES))
|
||||||
|
.filter((nom) => nom.endsWith('.js') && !nom.endsWith('.test.js') && nom !== 'aides.js')
|
||||||
|
.sort();
|
||||||
|
return Promise.all(noms.map(async (nom) => ({ table: nom.slice(0, -3), module: await import(`./libelles/${nom}`) })));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('libellés : les tables par domaine (§ 14.6)', () => {
|
||||||
|
test('chaque module de libelles/, aides.js excepté, exporte TABLE, une Map ; TABLES_APPLICATION les assemble, dix, par ordre alphabétique', async () => {
|
||||||
|
const tables = await tablesDuDossier();
|
||||||
|
assert.equal(tables.length, 10);
|
||||||
|
assert.deepEqual(tables.map(({ table }) => table), [
|
||||||
|
'csv', 'evenements', 'format', 'generation', 'mobilier', 'moteur', 'participants', 'places', 'seance', 'stockage',
|
||||||
|
]);
|
||||||
|
for (const { table, module } of tables) assert.ok(module.TABLE instanceof Map, table);
|
||||||
|
assert.equal(TABLES_APPLICATION.length, tables.length);
|
||||||
|
tables.forEach(({ table, module }, rang) => assert.equal(TABLES_APPLICATION[rang], module.TABLE, table));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chaque clé a l'une des deux formes ; un libellé commence par le nom de sa table ; participants et generation ne portent que des codes", async () => {
|
||||||
|
const tables = await tablesDuDossier();
|
||||||
|
let comptees = 0;
|
||||||
|
for (const { table, module } of tables) {
|
||||||
|
for (const cle of module.TABLE.keys()) {
|
||||||
|
comptees += 1;
|
||||||
|
const code = FORME_CODE.test(cle);
|
||||||
|
assert.ok(code || formeLibelle(table).test(cle), `${table} : clé ${JSON.stringify(cle)}`);
|
||||||
|
if (table === 'participants' || table === 'generation') assert.ok(code, `${table} : ${cle} n'est pas un code`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(comptees >= CODES.length, `${comptees} clés`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chaque texte d'A1 est dans la table du domaine qui l'émet", async () => {
|
||||||
|
const parTable = new Map((await tablesDuDossier()).map(({ table, module }) => [table, module.TABLE]));
|
||||||
|
const ATTENDUS = [
|
||||||
|
['stockage', ['ABSENT', 'VERROU_PRIS', 'LECTURE_SEULE', 'DERIVE', 'RELECTURE_DIFFERENTE', 'COMPTEUR_SATURE']],
|
||||||
|
['csv', ['NOM_ABSENT', 'UTF8_INVALIDE', 'ENREGISTRER_EN_UTF8', 'PLAN_BLOQUE', 'AMBIGUITE']],
|
||||||
|
['seance', ['MODE_LECTURE', 'SUPPORT_ROMPU', 'SANS_EFFET', 'ENTREE_DEFAIRE', 'JALON_CREATION']],
|
||||||
|
['participants', ['NOM_REQUIS', 'PARTICIPANT_INCONNU', 'RESERVATIONS_SUSPENDUES', 'IMPORT_EN_RETENU', 'ENTREE_AJOUTER_PARTICIPANT']],
|
||||||
|
['evenements', ['ENTREE_CREATION', 'DATE_INVALIDE', 'AUCUN_RETENU', 'PLAN_NON_BLOQUE', 'ENTREE_DEBLOQUER']],
|
||||||
|
['generation', ['PROPOSITION_INCONNUE', 'ENTREE_ENREGISTRER_GENERATION', 'ENTREE_EFFACER_PROPOSITIONS']],
|
||||||
|
['format', ['format.vide', 'format.duree.vingtMinutes']],
|
||||||
|
];
|
||||||
|
assert.equal(ATTENDUS.length, 7);
|
||||||
|
for (const [table, cles] of ATTENDUS) for (const cle of cles) assert.ok(parTable.get(table).has(cle), `${cle} hors de ${table}.js`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CODES ne compte que les clés de forme code ; libelle lit aussi un libellé de l'application", () => {
|
||||||
|
assert.ok(CODES.length > 0);
|
||||||
|
assert.deepEqual(CODES.filter((cle) => !FORME_CODE.test(cle)), []);
|
||||||
|
assert.ok(!CODES.includes('format.vide'));
|
||||||
|
assert.equal(libelle('format.vide'), '\u{2014}');
|
||||||
|
const toutes = TABLES_APPLICATION.flatMap((table) => [...table.keys()]).filter((cle) => FORME_CODE.test(cle));
|
||||||
|
assert.deepEqual([...CODES], [...toutes].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
102
src/application/libelles/aides.js
Normal file
102
src/application/libelles/aides.js
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Les aides de composition que partagent les tables de l'application : citer,
|
||||||
|
// compter, énumérer, dater, nommer un état. Ce module n'est pas une table et
|
||||||
|
// n'exporte aucune TABLE. Une aide qui lit le texte d'une autre clé reçoit
|
||||||
|
// texte, la fonction de composition que la traduction passe à chaque entrée
|
||||||
|
// (traduction.js) : elle ne connaît pas l'assemblage des tables.
|
||||||
|
//
|
||||||
|
// Les aides ne lisent ni l'horloge ni la langue du système : un horodatage se
|
||||||
|
// met en mots à partir de sa seule forme, AAAA-MM-JJTHH:MM:SS±HH:MM.
|
||||||
|
|
||||||
|
const NOMS_DES_ETATS = new Map([
|
||||||
|
['brouillon', 'Brouillon'],
|
||||||
|
['propose', 'Proposé'],
|
||||||
|
['retenu', 'Retenu'],
|
||||||
|
['bloque', 'Bloqué'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Valeur d'une table de noms, ou RangeError qui la nomme. */
|
||||||
|
export function nomDans(table, valeur, quoi) {
|
||||||
|
if (!table.has(valeur)) throw new RangeError(`libelle : ${quoi} inconnu ${JSON.stringify(valeur)}`);
|
||||||
|
return table.get(valeur);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Le nom d'un état du plan (§ 9) ; un état inconnu lève RangeError. */
|
||||||
|
export const nomEtat = (etat) => nomDans(NOMS_DES_ETATS, etat, 'état');
|
||||||
|
|
||||||
|
/** Un texte cité entre guillemets français. */
|
||||||
|
export const cite = (texte) => `« ${texte} »`;
|
||||||
|
|
||||||
|
/** Un compte et son nom, au singulier jusqu'à un, comme le veut le français. */
|
||||||
|
export const compte = (n, singulier, pluriel) => `${n} ${n <= 1 ? singulier : pluriel}`;
|
||||||
|
|
||||||
|
/** Une énumération : « a », « a et b », « a, b et c ». */
|
||||||
|
export function enumeration(termes) {
|
||||||
|
if (termes.length <= 1) return termes.join('');
|
||||||
|
return `${termes.slice(0, -1).join(', ')} et ${termes.at(-1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** La première lettre en capitale. */
|
||||||
|
export const capitale = (texte) => texte.charAt(0).toUpperCase() + texte.slice(1);
|
||||||
|
|
||||||
|
/** Le chemin de l'élément fautif, rien quand il manque ou désigne la racine. */
|
||||||
|
export const element = (chemin) => (typeof chemin === 'string' && chemin !== '' ? ` (${chemin})` : '');
|
||||||
|
|
||||||
|
/** Le chemin affichable d'une racine, reçue entière ou par son seul chemin. */
|
||||||
|
export const cheminDe = (racine) => (typeof racine === 'string' ? racine : racine?.chemin);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Une cause entre parenthèses, rien quand elle manque : son texte quand les
|
||||||
|
* tables en portent un, sinon la cause telle que le système la nomme.
|
||||||
|
*/
|
||||||
|
export function causeEntreParentheses(cause, texte) {
|
||||||
|
if (cause === null || cause === undefined || cause === '') return '';
|
||||||
|
return ` (${texte.existe(cause) ? texte(cause) : cause})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ce qu'un refus d'écriture nomme : le fichier, son dossier, la cause. */
|
||||||
|
export const ouEcrire = ({ chemin, dossier, cause }, texte) =>
|
||||||
|
`${cite(chemin)} dans le dossier ${cite(dossier)}${causeEntreParentheses(cause, texte)}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Une raison codée en une phrase : son texte quand les tables en portent un
|
||||||
|
* et que les détails reçus lui suffisent ; sinon une phrase générique qui la
|
||||||
|
* cite telle quelle. Un texte qui attend ses propres détails lève ou rend
|
||||||
|
* « undefined » sans eux : la phrase générique le remplace alors.
|
||||||
|
*/
|
||||||
|
export function phraseDeRaison(raison, details, texte) {
|
||||||
|
const generique = `La raison donnée est ${cite(String(raison))}.`;
|
||||||
|
if (!texte.existe(raison)) return generique;
|
||||||
|
try {
|
||||||
|
const rendu = texte(raison, details);
|
||||||
|
return typeof rendu === 'string' && !rendu.includes('undefined') ? rendu : generique;
|
||||||
|
} catch {
|
||||||
|
return generique;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Des numéros de proposition, un illisible nommé comme tel. */
|
||||||
|
export const numeros = (ids) => enumeration(ids.map((id) => (id === null ? 'sans numéro lisible' : `n° ${id}`)));
|
||||||
|
|
||||||
|
// La forme d'un horodatage : date, heure et minute se lisent, les secondes et
|
||||||
|
// le décalage se taisent.
|
||||||
|
const HORODATAGE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):\d{2}[+-]\d{2}:\d{2}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un horodatage AAAA-MM-JJTHH:MM:SS±HH:MM en mots, « le 17 mai 2031 à
|
||||||
|
* 8 h 05 », à l'heure qu'il porte, par les textes de la mise en forme
|
||||||
|
* (format.date, format.heure) ; une autre forme se cite telle quelle.
|
||||||
|
*/
|
||||||
|
export function dater(horodatage, texte) {
|
||||||
|
const morceaux = HORODATAGE.exec(String(horodatage));
|
||||||
|
if (morceaux === null) return String(horodatage);
|
||||||
|
const [annee, mois, jour, heure, minute] = morceaux.slice(1).map(Number);
|
||||||
|
if (mois < 1 || mois > 12) return String(horodatage);
|
||||||
|
return `le ${texte('format.date', { annee, mois, jour })} à ${texte('format.heure', { heure, minute })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** « depuis » et l'horodatage en mots, rien quand il manque. */
|
||||||
|
export const depuisQuand = (depuis, texte) =>
|
||||||
|
depuis === null || depuis === undefined ? '' : ` depuis ${dater(depuis, texte)}`;
|
||||||
62
src/application/libelles/csv.js
Normal file
62
src/application/libelles/csv.js
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Les textes des codes de src/csv (§ 10.1, § 14.6) : encodage, séparateur,
|
||||||
|
// en-tête, refus d'une ligne, refus de la mise à jour.
|
||||||
|
import { cite, enumeration } from './aides.js';
|
||||||
|
|
||||||
|
// Le remède commun aux refus d'encodage (§ 10.1).
|
||||||
|
const REMEDE_UTF8 = 'Réenregistrez le fichier en UTF-8 depuis le tableur, puis importez-le de nouveau.';
|
||||||
|
const refusEncodage = (constat) => () => `${constat} ${REMEDE_UTF8}`;
|
||||||
|
|
||||||
|
export const TABLE = new Map([
|
||||||
|
// --- Le CSV (§ 10.1) -------------------------------------------------------
|
||||||
|
['UTF16_SANS_MARQUE', refusEncodage("Le fichier semble encodé en UTF-16, sans la marque qui l'annonce.")],
|
||||||
|
['UTF16_INVALIDE', refusEncodage("Le fichier s'annonce en UTF-16, mais ses octets ne le sont pas.")],
|
||||||
|
['UTF8_INVALIDE', refusEncodage("Le fichier s'annonce en UTF-8, mais ses octets ne le sont pas.")],
|
||||||
|
['CARACTERE_NUL', refusEncodage("Le texte porte un caractère nul, qu'aucune liste ne contient.")],
|
||||||
|
['ENREGISTRER_EN_UTF8', () => REMEDE_UTF8],
|
||||||
|
[
|
||||||
|
'SEPARATEUR_INTROUVABLE',
|
||||||
|
({ ligne, cause }, texte) => {
|
||||||
|
const constat = 'Aucun séparateur ne convient : ni le point-virgule, ni la virgule, ni la tabulation.';
|
||||||
|
if (ligne === null || ligne === undefined) return constat;
|
||||||
|
const ecart = texte(cause === 'GUILLEMET_OUVERT' ? 'GUILLEMET_OUVERT' : 'NOMBRE_DE_CHAMPS', { ligne });
|
||||||
|
return `${constat} ${ecart}`;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
['NOMBRE_DE_CHAMPS', ({ ligne }) => `Avec chacun, la ligne ${ligne} n'a pas le nombre de champs de l'en-tête.`],
|
||||||
|
[
|
||||||
|
'GUILLEMET_OUVERT',
|
||||||
|
({ ligne }) =>
|
||||||
|
`Un guillemet ouvert${Number.isInteger(ligne) ? ` à la ligne ${ligne}` : ''} ne se referme pas : ` +
|
||||||
|
'tout ce qui le suit tiendrait dans un seul champ.',
|
||||||
|
],
|
||||||
|
['AUCUN_ENTETE', () => "Aucun en-tête n'est reconnu : la première ligne nomme les colonnes, « nom » au moins."],
|
||||||
|
['NOM_NON_ASSOCIE', () => "Aucune colonne ne porte le nom : l'en-tête doit compter une colonne « nom »."],
|
||||||
|
['AUCUNE_LIGNE_VALIDE', () => "Aucune ligne n'est valide : rien n'est importé."],
|
||||||
|
['CHAMPS_EN_TROP', () => "La ligne compte plus de champs que l'en-tête."],
|
||||||
|
['NOM_ABSENT', () => 'Le nom manque.'],
|
||||||
|
[
|
||||||
|
'EXCLU_INCONNU',
|
||||||
|
({ valeur }) =>
|
||||||
|
`La valeur d'« exclu » ${cite(valeur)} n'est pas reconnue : ` +
|
||||||
|
'oui, o, vrai, 1 ou x pour exclure ; non, n, faux, 0 ou vide sinon.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'PLUSIEURS_CORRESPONDENT',
|
||||||
|
({ participants }) =>
|
||||||
|
'Plusieurs personnes de la liste correspondent à cette ligne ' +
|
||||||
|
`(${enumeration(participants.map((id) => `n° ${id}`))}) : rien n'est fusionné.`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'DEJA_DESIGNE',
|
||||||
|
({ premiereLigne }) => `La ligne ${premiereLigne} désigne déjà cette personne : rien n'est fusionné.`,
|
||||||
|
],
|
||||||
|
['PLAN_BLOQUE', () => 'Le plan est bloqué : « Débloquer » le rend de nouveau modifiable.'],
|
||||||
|
[
|
||||||
|
'AMBIGUITE',
|
||||||
|
({ champs }) =>
|
||||||
|
`Plusieurs colonnes répondent au même champ (${enumeration(champs)}) : choisissez celle qui s'importe.`,
|
||||||
|
],
|
||||||
|
]);
|
||||||
22
src/application/libelles/evenements.js
Normal file
22
src/application/libelles/evenements.js
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Les textes de la création d'un événement, de ses états et du déblocage
|
||||||
|
// (§ 8.1, § 9, § 14.6) : refus et libellés figés. Un état inconnu lève
|
||||||
|
// RangeError, au lieu de se taire.
|
||||||
|
import { cite, nomEtat } from './aides.js';
|
||||||
|
|
||||||
|
export const TABLE = new Map([
|
||||||
|
// --- Les refus de la création, des états et du déblocage (§ 8.1, § 9) -----
|
||||||
|
['PLAN_NON_BLOQUE', () => "Le plan n'est pas bloqué : il n'y a rien à débloquer."],
|
||||||
|
['DATE_INVALIDE', ({ date }) => `La date ${cite(String(date))} n'est pas un jour du calendrier, écrit AAAA-MM-JJ.`],
|
||||||
|
['SIEGES_INVALIDES', () => 'Une table compte au moins deux sièges.'],
|
||||||
|
['TOURS_INVALIDES', () => 'La soirée compte au moins un tour.'],
|
||||||
|
['AUCUNE_PROPOSITION', () => "Aucune proposition n'existe : l'état « Proposé » suit une génération."],
|
||||||
|
['AUCUN_RETENU', () => "Aucun placement n'est retenu : l'état « Retenu » suit le choix d'une proposition."],
|
||||||
|
|
||||||
|
// --- Les libellés figés du journal (§ 8.2, § 8.3) --------------------------
|
||||||
|
['ENTREE_CREATION', ({ nom }) => `Création de l'événement ${cite(nom)}`],
|
||||||
|
['ENTREE_CHANGER_ETAT', ({ etat }) => `Plan passé à l'état ${cite(nomEtat(etat))}`],
|
||||||
|
['ENTREE_DEBLOQUER', ({ etat }) => `Plan débloqué, état ${cite(nomEtat(etat))}`],
|
||||||
|
]);
|
||||||
60
src/application/libelles/format.js
Normal file
60
src/application/libelles/format.js
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Les textes de la mise en forme (format.js, § 5.4, § 5.10, § 18.4) : la
|
||||||
|
// valeur absente, les unités d'une durée, son ordre de grandeur, les mois et
|
||||||
|
// l'heure. Les entrées reçoivent des nombres déjà contrôlés par creerFormat,
|
||||||
|
// ou des chiffres déjà mis en forme ; un mois hors de 1 à 12 lève RangeError.
|
||||||
|
|
||||||
|
const MOIS = [
|
||||||
|
'janvier',
|
||||||
|
'février',
|
||||||
|
'mars',
|
||||||
|
'avril',
|
||||||
|
'mai',
|
||||||
|
'juin',
|
||||||
|
'juillet',
|
||||||
|
'août',
|
||||||
|
'septembre',
|
||||||
|
'octobre',
|
||||||
|
'novembre',
|
||||||
|
'décembre',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Le nom d'un mois, de 1 à 12.
|
||||||
|
function nomDuMois(mois) {
|
||||||
|
if (!Number.isInteger(mois) || mois < 1 || mois > 12) {
|
||||||
|
throw new RangeError(`format : mois inconnu ${JSON.stringify(mois)}`);
|
||||||
|
}
|
||||||
|
return MOIS[mois - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TABLE = new Map([
|
||||||
|
// Une valeur absente, celle d'une population vide (§ 5.4).
|
||||||
|
['format.vide', '\u{2014}'],
|
||||||
|
|
||||||
|
// Une durée : secondes, déjà écrites ; minutes et secondes ; heures et
|
||||||
|
// minutes. Une part nulle se tait.
|
||||||
|
['format.duree.secondes', ({ secondes }) => `${secondes} s`],
|
||||||
|
[
|
||||||
|
'format.duree.minutes',
|
||||||
|
({ minutes, secondes }) => (secondes === 0 ? `${minutes} min` : `${minutes} min ${secondes} s`),
|
||||||
|
],
|
||||||
|
['format.duree.heures', ({ heures, minutes }) => (minutes === 0 ? `${heures} h` : `${heures} h ${minutes} min`)],
|
||||||
|
|
||||||
|
// L'ordre de grandeur d'une durée (§ 5.10), du plus court au plus long.
|
||||||
|
['format.duree.moinsDUneMinute', "moins d'une minute"],
|
||||||
|
['format.duree.deuxMinutes', 'environ deux minutes'],
|
||||||
|
['format.duree.cinqMinutes', 'environ cinq minutes'],
|
||||||
|
['format.duree.dixMinutes', 'environ dix minutes'],
|
||||||
|
['format.duree.vingtMinutes', 'environ vingt minutes'],
|
||||||
|
['format.duree.demiHeure', 'environ une demi-heure'],
|
||||||
|
['format.duree.uneHeure', 'environ une heure'],
|
||||||
|
['format.duree.plusieursHeures', 'plusieurs heures'],
|
||||||
|
|
||||||
|
// Une date en lettres, « 1er » pour le premier du mois (§ 18.4) ; l'heure
|
||||||
|
// sans zéro de tête, la minute sur deux chiffres.
|
||||||
|
['format.date', ({ annee, mois, jour }) => `${jour === 1 ? '1er' : jour} ${nomDuMois(mois)} ${annee}`],
|
||||||
|
['format.heure', ({ heure, minute }) => `${heure} h ${String(minute).padStart(2, '0')}`],
|
||||||
|
['format.horodatage', (details, texte) => `${texte('format.date', details)}, ${texte('format.heure', details)}`],
|
||||||
|
]);
|
||||||
26
src/application/libelles/generation.js
Normal file
26
src/application/libelles/generation.js
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Les textes des commandes de la génération et des propositions (§ 5.7,
|
||||||
|
// § 14.6) : refus et libellés figés. Cette table ne porte que des codes.
|
||||||
|
import { compte } from './aides.js';
|
||||||
|
|
||||||
|
export const TABLE = new Map([
|
||||||
|
// --- Les refus des propositions (§ 5.7) ------------------------------------
|
||||||
|
['PROPOSITION_INCONNUE', ({ id }) => `La proposition n° ${id} n'est pas dans cet événement.`],
|
||||||
|
|
||||||
|
// --- Les libellés figés du journal (§ 8.2, § 8.3) --------------------------
|
||||||
|
[
|
||||||
|
'ENTREE_ENREGISTRER_GENERATION',
|
||||||
|
({ n, premier, dernier }) =>
|
||||||
|
`Généré ${compte(n, 'proposition', 'propositions')} ` +
|
||||||
|
`(n° ${premier === dernier ? premier : `${premier} à ${dernier}`})`,
|
||||||
|
],
|
||||||
|
['ENTREE_RETENIR_PROPOSITION', ({ id }) => `Retenu la proposition n° ${id}`],
|
||||||
|
[
|
||||||
|
'ENTREE_EFFACER_PROPOSITIONS',
|
||||||
|
({ n, gardee }) =>
|
||||||
|
`Effacé ${compte(n, 'proposition', 'propositions')}` +
|
||||||
|
`${gardee === null || gardee === undefined ? '' : `, gardé la retenue (n° ${gardee})`}`,
|
||||||
|
],
|
||||||
|
]);
|
||||||
5
src/application/libelles/mobilier.js
Normal file
5
src/application/libelles/mobilier.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Les textes des commandes du mobilier — tables, sièges, tours (§ 6, § 14.6).
|
||||||
|
export const TABLE = new Map();
|
||||||
6
src/application/libelles/moteur.js
Normal file
6
src/application/libelles/moteur.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Les textes des codes du moteur qui n'ont pas leur table ailleurs, et les
|
||||||
|
// libellés de ses critères (§ 5, § 14.6).
|
||||||
|
export const TABLE = new Map();
|
||||||
80
src/application/libelles/participants.js
Normal file
80
src/application/libelles/participants.js
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Les textes des commandes de la liste des participants et de l'import
|
||||||
|
// (§ 4.4, § 10.2, § 14.6) : leurs refus, leurs avertissements et leurs
|
||||||
|
// libellés figés. Cette table ne porte que des codes. Un champ inconnu lève
|
||||||
|
// RangeError, au lieu de se taire.
|
||||||
|
import { compte, nomDans } from './aides.js';
|
||||||
|
|
||||||
|
// Les champs d'un participant qu'une saisie modifie, dans l'ordre du fichier.
|
||||||
|
const NOMS_DES_CHAMPS = new Map([
|
||||||
|
['nom', 'nom'],
|
||||||
|
['prenom', 'prénom'],
|
||||||
|
['appartenance', 'appartenance'],
|
||||||
|
['courriel', 'courriel'],
|
||||||
|
['titrePressenti', 'titre pressenti'],
|
||||||
|
['notes', 'notes'],
|
||||||
|
]);
|
||||||
|
const nomChamp = (champ) => nomDans(NOMS_DES_CHAMPS, champ, 'champ');
|
||||||
|
|
||||||
|
// Les titres de place qu'un geste sur une personne touche, quand il y en a.
|
||||||
|
const titresDe = (n, singulier, pluriel) =>
|
||||||
|
n > 0 ? `, et ${compte(n, 'titre de place', 'titres de place')} ${n <= 1 ? singulier : pluriel}` : '';
|
||||||
|
|
||||||
|
export const TABLE = new Map([
|
||||||
|
// --- Les refus et les avertissements des commandes (§ 4.4, § 10.2) ---------
|
||||||
|
['NOM_REQUIS', () => 'Le nom est obligatoire.'],
|
||||||
|
['PARTICIPANT_INCONNU', ({ id }) => `La personne n° ${id} n'est pas dans cet événement.`],
|
||||||
|
[
|
||||||
|
'RESERVATIONS_SUSPENDUES',
|
||||||
|
({ personne, reservations, titres }) =>
|
||||||
|
`Exclusion de ${personne} : ${compte(reservations, 'réservation suspendue', 'réservations suspendues')}, ` +
|
||||||
|
`ses places reviennent aux autres${titresDe(titres, "n'est plus pourvu", 'ne sont plus pourvus')}.`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'RESERVATIONS_RETABLIES',
|
||||||
|
({ personne, reservations, titres }) =>
|
||||||
|
`Réintégration de ${personne} : ${compte(reservations, 'réservation rétablie', 'réservations rétablies')}` +
|
||||||
|
`${titresDe(titres, 'de nouveau pourvu', 'de nouveau pourvus')}.`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'RESERVATIONS_RETIREES',
|
||||||
|
({ personne, reservations, titres }) =>
|
||||||
|
`Suppression de ${personne} : ${compte(reservations, 'réservation retirée', 'réservations retirées')}` +
|
||||||
|
`${titresDe(titres, "n'est plus pourvu", 'ne sont plus pourvus')}.`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'IMPORT_EN_RETENU',
|
||||||
|
() =>
|
||||||
|
"L'import change la liste après le choix d'un placement : le placement retenu est gardé tel quel, " +
|
||||||
|
"et rien n'est effacé ; à vous de juger s'il convient encore.",
|
||||||
|
],
|
||||||
|
|
||||||
|
// --- Les libellés figés du journal (§ 8.2, § 8.3) --------------------------
|
||||||
|
['ENTREE_AJOUTER_PARTICIPANT', ({ personne }) => `Ajouté ${personne}`],
|
||||||
|
[
|
||||||
|
'ENTREE_MODIFIER_PARTICIPANT',
|
||||||
|
({ personne, champs, devenu }) => {
|
||||||
|
const nouveau = devenu === null || devenu === undefined ? '' : ` (désormais ${devenu})`;
|
||||||
|
return `Modifié ${personne} : ${champs.map(nomChamp).join(', ')}${nouveau}`;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
['ENTREE_EXCLURE_PARTICIPANT', ({ personne }) => `Exclu ${personne}`],
|
||||||
|
['ENTREE_REINTEGRER_PARTICIPANT', ({ personne }) => `Réintégré ${personne}`],
|
||||||
|
['ENTREE_SUPPRIMER_PARTICIPANT', ({ personne }) => `Supprimé ${personne}`],
|
||||||
|
[
|
||||||
|
'ENTREE_IMPORTER_PARTICIPANTS',
|
||||||
|
({ mode, ajoutes, misAJour, retires }) => {
|
||||||
|
const ajoutesTexte = compte(ajoutes, 'ajouté', 'ajoutés');
|
||||||
|
if (mode === 'ajouter') return `Importé ${compte(ajoutes, 'participant', 'participants')}`;
|
||||||
|
if (mode === 'mettreAJour') {
|
||||||
|
return `Mis à jour la liste par import : ${ajoutesTexte}, ${compte(misAJour, 'modifié', 'modifiés')}`;
|
||||||
|
}
|
||||||
|
if (mode === 'remplacer') {
|
||||||
|
return `Remplacé la liste par import : ${compte(retires, 'retiré', 'retirés')}, ${ajoutesTexte}`;
|
||||||
|
}
|
||||||
|
throw new RangeError(`libelle : mode d'import inconnu ${JSON.stringify(mode)}`);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
6
src/application/libelles/places.js
Normal file
6
src/application/libelles/places.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Les textes des commandes des places — réservations, titres, pose (§ 5.8,
|
||||||
|
// § 5.9, § 14.6).
|
||||||
|
export const TABLE = new Map();
|
||||||
98
src/application/libelles/seance.js
Normal file
98
src/application/libelles/seance.js
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Les textes des codes de la séance (§ 8.3 à § 8.8, § 14.6), et les libellés
|
||||||
|
// figés que la séance écrit au journal : jalons, reprise, retours. Un libellé
|
||||||
|
// d'entrée se compose une fois, à l'écriture du geste, et le journal en garde
|
||||||
|
// le texte, jamais le code (§ 8.2).
|
||||||
|
import { causeEntreParentheses, cite, depuisQuand, nomEtat, ouEcrire } from './aides.js';
|
||||||
|
|
||||||
|
export const TABLE = new Map([
|
||||||
|
// --- La séance (§ 8.3, § 8.4, § 8.5, § 8.6, § 8.8) -------------------------
|
||||||
|
['AUCUN_EVENEMENT', () => "Aucun événement n'est ouvert."],
|
||||||
|
['MODE_LECTURE', () => "L'événement est ouvert en lecture : « Modifier » permet de le changer."],
|
||||||
|
['FORMAT', (details, texte) => texte('FORMAT_PLUS_RECENT', details)],
|
||||||
|
['BLOQUE', () => 'Le plan est bloqué.'],
|
||||||
|
// echec nomme ce que le support n'a pas reçu : la ligne d'un geste
|
||||||
|
// (ECRITURE), l'état d'un geste inscrit au journal (ETAT_NON_ECRIT), le
|
||||||
|
// jalon d'un geste enregistré (JALON_NON_ECRIT), ou le verrou d'un passage
|
||||||
|
// en écriture (VERROU_NON_ECRIT), qui ne perd rien.
|
||||||
|
[
|
||||||
|
'SUPPORT_ROMPU',
|
||||||
|
({ echec, ...details }, texte) => {
|
||||||
|
const refus = `Le support ne prend plus les écritures : ${ouEcrire(details, texte)} n'a pas pu s'écrire`;
|
||||||
|
const ailleurs = "« Écrire ailleurs » enregistre l'événement dans un autre dossier.";
|
||||||
|
if (echec === 'ECRITURE' || echec === 'ETAT_NON_ECRIT') {
|
||||||
|
const perte =
|
||||||
|
echec === 'ETAT_NON_ECRIT'
|
||||||
|
? "le dernier geste est inscrit à l'historique, mais pas dans l'état"
|
||||||
|
: "le dernier geste n'est pas enregistré";
|
||||||
|
return `${refus}, et ${perte}. Sur ce support, l'historique et l'état ne se correspondent plus ; ${ailleurs}`;
|
||||||
|
}
|
||||||
|
if (echec === 'JALON_NON_ECRIT') {
|
||||||
|
return (
|
||||||
|
`${refus}. Le dernier geste est enregistré, mais pas le jalon qui le nomme, ` +
|
||||||
|
`et aucun geste ne s'y enregistrerait plus ; ${ailleurs}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (echec === 'VERROU_NON_ECRIT') {
|
||||||
|
return (
|
||||||
|
`${refus}. L'événement y reste tel qu'il s'affiche, ` +
|
||||||
|
`mais aucun geste ne s'y enregistrerait ; ${ailleurs}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new RangeError(`libelle : échec du support inconnu ${JSON.stringify(echec)}`);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
['SUPPORT_INTACT', () => "Le support prend encore les écritures : l'événement reste où il est."],
|
||||||
|
// Quitter l'événement d'un support rompu : la séance tient la seule copie
|
||||||
|
// que l'écriture ailleurs peut encore sauver ; ETAT_NON_ECRIT dit que son
|
||||||
|
// dernier geste n'est qu'à l'historique de ce support.
|
||||||
|
[
|
||||||
|
'SUPPORT_ROMPU_NON_SAUVE',
|
||||||
|
({ echec, dossier }) => {
|
||||||
|
const geste =
|
||||||
|
echec === 'ETAT_NON_ECRIT' ? ", et son dernier geste n'y est inscrit qu'à l'historique" : '';
|
||||||
|
return (
|
||||||
|
`Le support ${cite(dossier)} ne prend plus les écritures${geste} : ` +
|
||||||
|
"l'événement ouvert n'est sauvé dans aucun autre dossier. " +
|
||||||
|
"« Écrire ailleurs » l'enregistre d'abord ; le quitter maintenant l'abandonne à ce support."
|
||||||
|
);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'EVENEMENT_CHANGE',
|
||||||
|
({ revision }) =>
|
||||||
|
`Une autre séance a modifié cet événement depuis son ouverture : il s'affiche tel qu'elle l'a laissé, ` +
|
||||||
|
`à la révision ${revision}, et les gestes suivants s'y ajoutent.`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'VERROU_REPRIS',
|
||||||
|
({ depuis }, texte) => `Le verrou qu'une séance interrompue de ce poste avait laissé${depuisQuand(depuis, texte)} est repris.`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'VERROU_NON_RENDU',
|
||||||
|
({ cause }, texte) =>
|
||||||
|
`Le verrou de l'événement n'a pas pu être rendu${causeEntreParentheses(cause, texte)} : ` +
|
||||||
|
'une autre séance de ce poste le reprendra une fois ce logiciel fermé.',
|
||||||
|
],
|
||||||
|
['RIEN_A_DEFAIRE', () => "Rien à défaire : l'historique commence à cet instant."],
|
||||||
|
['RIEN_A_REFAIRE', () => 'Rien à refaire : seul un retour se refait, tant que nul geste ne le suit.'],
|
||||||
|
['INSTANT_NON_RESTITUABLE', ({ revision }) => `L'historique ne restitue plus l'instant ${revision}.`],
|
||||||
|
[
|
||||||
|
'JALON_NON_ECRIT',
|
||||||
|
({ dossier, cause }, texte) =>
|
||||||
|
`Le geste est enregistré, mais le jalon qui le nomme n'a pas pu s'écrire ` +
|
||||||
|
`dans le dossier ${cite(dossier)}${causeEntreParentheses(cause, texte)}.`,
|
||||||
|
],
|
||||||
|
['SANS_EFFET', () => "Ce geste ne change rien : rien n'est enregistré."],
|
||||||
|
|
||||||
|
// --- Les libellés figés du journal (§ 8.2, § 8.3) --------------------------
|
||||||
|
['JALON_CREATION', () => 'Création'],
|
||||||
|
['JALON_ETAT', ({ etat }) => `État ${cite(nomEtat(etat))}`],
|
||||||
|
['JALON_AVANT_GENERATION', () => 'Avant la génération'],
|
||||||
|
['ENTREE_REPRISE', () => "Reprise de l'état trouvé sur le disque"],
|
||||||
|
['ENTREE_DEFAIRE', ({ libelle: instant }) => `Défait : ${cite(instant)}`],
|
||||||
|
['ENTREE_REFAIRE', ({ libelle: instant }) => `Refait : ${cite(instant)}`],
|
||||||
|
['ENTREE_REVENIR', ({ libelle: instant }) => `Retour à ${cite(instant)}`],
|
||||||
|
]);
|
||||||
319
src/application/libelles/stockage.js
Normal file
319
src/application/libelles/stockage.js
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Les textes des codes de src/stockage (§ 14.6) : le système de fichiers et le
|
||||||
|
// dépôt, les raisons d'un fichier illisible, les fautes et les raisons de
|
||||||
|
// dérive d'un placement, ce que l'ouverture annonce, le dossier de travail et
|
||||||
|
// les réglages locaux. Une raison qu'un texte ne traite pas à part ne lève
|
||||||
|
// jamais : une phrase générique la nomme, par son texte quand les tables en
|
||||||
|
// portent un, telle quelle sinon. Une cause que nomme le système, EROFS,
|
||||||
|
// ENOENT ou TypeMismatchError, n'est pas un code de l'application : les
|
||||||
|
// textes la citent telle quelle, entre parenthèses.
|
||||||
|
import {
|
||||||
|
capitale,
|
||||||
|
causeEntreParentheses,
|
||||||
|
cheminDe,
|
||||||
|
cite,
|
||||||
|
compte,
|
||||||
|
depuisQuand,
|
||||||
|
element,
|
||||||
|
enumeration,
|
||||||
|
numeros,
|
||||||
|
ouEcrire,
|
||||||
|
phraseDeRaison,
|
||||||
|
} from './aides.js';
|
||||||
|
|
||||||
|
// La raison d'un fichier d'état illisible, en une phrase.
|
||||||
|
function raisonIllisible({ raison, ...details }, texte) {
|
||||||
|
if (raison === 'ABSENT') return "Le fichier d'état manque, à côté de son historique.";
|
||||||
|
return phraseDeRaison(raison, details, texte);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les secours d'un fichier d'état illisible, quand l'erreur les nomme.
|
||||||
|
function secoursDe(secours) {
|
||||||
|
if (secours === undefined || secours === null) return '';
|
||||||
|
const offerts = [];
|
||||||
|
if (secours.precedent !== null) offerts.push(`la version précédente du fichier (révision ${secours.precedent})`);
|
||||||
|
if (secours.journal !== null) offerts.push(`le dernier instant de l'historique (révision ${secours.journal})`);
|
||||||
|
if (offerts.length === 0) return ' Aucune version de secours ne se lit.';
|
||||||
|
return ` ${capitale(enumeration(offerts))} ${offerts.length === 1 ? 'peut' : 'peuvent'} se rouvrir.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ce qu'une dérive touche, du retenu aux propositions, et l'accord du verbe.
|
||||||
|
function touchees({ ids, retenu }) {
|
||||||
|
const parties = [];
|
||||||
|
if (retenu) parties.push('le placement retenu');
|
||||||
|
if (ids.length === 1) parties.push(`la proposition n° ${ids[0]}`);
|
||||||
|
if (ids.length > 1) parties.push(`les propositions n° ${enumeration(ids.map(String))}`);
|
||||||
|
return { texte: capitale(enumeration(parties)), pluriel: (retenu ? 1 : 0) + ids.length > 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TABLE = new Map([
|
||||||
|
// --- Le système de fichiers et le dépôt (src/stockage/types.js) ------------
|
||||||
|
[
|
||||||
|
'ABSENT',
|
||||||
|
({ chemin }) =>
|
||||||
|
typeof chemin === 'string' ? `Le fichier ${cite(chemin)} est introuvable.` : "Le fichier d'état manque.",
|
||||||
|
],
|
||||||
|
['ECRITURE', (details, texte) => `L'écriture de ${ouEcrire(details, texte)} a échoué ; le fichier d'avant reste intact.`],
|
||||||
|
['LECTURE', (details, texte) => `La lecture de ${ouEcrire(details, texte)} est refusée.`],
|
||||||
|
['EXISTE', ({ chemin }) => `Un fichier ${cite(chemin)} existe déjà.`],
|
||||||
|
['CHEMIN_REFUSE', ({ chemin }) => `Le chemin ${cite(String(chemin))} est refusé.`],
|
||||||
|
[
|
||||||
|
'ETAT_ILLISIBLE',
|
||||||
|
(details, texte) => {
|
||||||
|
const sujet = typeof details.base === 'string' ? `Le fichier d'état de ${cite(details.base)}` : 'Le fichier';
|
||||||
|
return `${sujet} ne s'ouvre pas. ${raisonIllisible(details, texte)}${secoursDe(details.secours)}`;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
['CORRECTIF', ({ rang }) => `Un correctif de l'historique ne s'applique pas (opération ${rang}).`],
|
||||||
|
[
|
||||||
|
'CHEMIN_TROP_LONG',
|
||||||
|
({ racine }) =>
|
||||||
|
`Le dossier ${cite(cheminDe(racine))} est trop profond : aucun nom de fichier n'y tient ` +
|
||||||
|
'sous la longueur de chemin que Windows accepte.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'IDENTIFIANT_PRESENT',
|
||||||
|
({ base }) =>
|
||||||
|
`Cet événement est déjà dans ce dossier, sous ${cite(base)} : ` +
|
||||||
|
"l'y placer une seconde fois en ferait deux copies de même identifiant.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'VERROU_PRIS',
|
||||||
|
({ depuis, vivant }, texte) => {
|
||||||
|
if (vivant === true) {
|
||||||
|
return `Une autre séance modifie cet événement${depuisQuand(depuis, texte)} : il reste en lecture ici.`;
|
||||||
|
}
|
||||||
|
if (vivant === false) {
|
||||||
|
return (
|
||||||
|
`Une séance interrompue a laissé son verrou${depuisQuand(depuis, texte)}, et il n'a pas pu se reprendre : ` +
|
||||||
|
"l'événement reste en lecture."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
`Un verrou dont la séance ne se laisse pas vérifier — autre poste, verrou illisible, ou séance de ce poste dont la vie ne se prouve pas — tient cet événement${depuisQuand(depuis, texte)} : ` +
|
||||||
|
'il reste en lecture ici, et rien ne le reprend seul.'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'LECTURE_SEULE',
|
||||||
|
({ raison }, texte) => {
|
||||||
|
if (raison === 'BLOQUE') {
|
||||||
|
return `${texte('BLOQUE')} Rien ne peut changer ; « Débloquer » le rend de nouveau modifiable.`;
|
||||||
|
}
|
||||||
|
if (raison === 'FORMAT' || raison === 'FORMAT_PLUS_RECENT') {
|
||||||
|
return `${texte(raison)} Il s'ouvre en lecture seule, et aucun geste ne le modifie ici.`;
|
||||||
|
}
|
||||||
|
if (raison === 'ENCODAGE') {
|
||||||
|
return (
|
||||||
|
`Le fichier d'état a changé sur le disque. ${texte('ENCODAGE')} ` +
|
||||||
|
"L'événement reste en lecture seule ; rouvert par une version de secours, son premier geste " +
|
||||||
|
"range ce fichier dans la corbeille avant d'écrire l'état."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return `${phraseDeRaison(raison, {}, texte)} L'événement reste en lecture seule, et aucun geste ne le modifie ici.`;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'ETAT_NON_ECRIT',
|
||||||
|
(details, texte) => `Le geste est inscrit à l'historique, mais l'état ${ouEcrire(details, texte)} n'a pas pu s'écrire.`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'CORBEILLE_SATUREE',
|
||||||
|
({ dossier }) =>
|
||||||
|
`La corbeille ${cite(dossier)} a reçu trop de suppressions dans la même seconde : ` +
|
||||||
|
'la suppression se refait une seconde plus tard.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'COMPTEUR_SATURE',
|
||||||
|
({ compteur }) =>
|
||||||
|
`Les numéros ${compteur === 'proposition' ? 'de proposition' : `du compteur ${cite(compteur)}`} sont épuisés : ` +
|
||||||
|
'rien de neuf ne se numérote plus dans cet événement.',
|
||||||
|
],
|
||||||
|
['NON_DISPONIBLE', ({ cause }, texte) => `Cette commande n'est pas offerte ici${causeEntreParentheses(cause, texte)}.`],
|
||||||
|
|
||||||
|
// --- Les raisons d'un fichier illisible ------------------------------------
|
||||||
|
['VIDE', () => 'Le fichier est vide.'],
|
||||||
|
['JSON', () => "Le texte n'est pas du JSON valide."],
|
||||||
|
['FORME', ({ chemin }) => `Une valeur sort de sa règle${element(chemin)}.`],
|
||||||
|
['COMPTES', ({ chemin }) => `Un compte de l'en-tête contredit sa liste${element(chemin)}.`],
|
||||||
|
['REFERENCE', ({ chemin }) => `Une réservation ou un titre désigne ce qui n'existe pas${element(chemin)}.`],
|
||||||
|
['FORMAT_INCONNU', () => "Le format du fichier n'est pas reconnu."],
|
||||||
|
[
|
||||||
|
'FORMAT_PLUS_RECENT',
|
||||||
|
({ format }) => {
|
||||||
|
const quel = Number.isInteger(format) ? ` (format ${format})` : '';
|
||||||
|
return `Ce fichier vient d'une version plus récente du logiciel${quel}.`;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'ENCODAGE',
|
||||||
|
() => "Ses octets ne sont pas de l'UTF-8 : un éditeur l'a sans doute réenregistré dans un autre encodage.",
|
||||||
|
],
|
||||||
|
|
||||||
|
// --- Les fautes d'un placement, et ses raisons de dérive (§ 8.9, § 9) ------
|
||||||
|
[
|
||||||
|
'LONGUEUR',
|
||||||
|
({ tour, table, declare, ecrit }) => {
|
||||||
|
if (tour === null) return `Le placement déclare ${compte(declare, 'tour', 'tours')} et en porte ${ecrit}.`;
|
||||||
|
if (table === null) {
|
||||||
|
return `Au tour ${tour}, le placement déclare ${compte(declare, 'table', 'tables')} et en porte ${ecrit}.`;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
`Au tour ${tour}, la table ${table} reçoit ${compte(ecrit, 'personne', 'personnes')} ` +
|
||||||
|
`pour ${compte(declare, 'place', 'places')}.`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
['DOUBLON', ({ tour, participant }) => `Au tour ${tour}, la personne n° ${participant} est placée plus d'une fois.`],
|
||||||
|
[
|
||||||
|
'INCONNU',
|
||||||
|
({ tour, participant }) => `Au tour ${tour}, la personne n° ${participant} est placée sans être déclarée.`,
|
||||||
|
],
|
||||||
|
['MANQUANT', ({ tour, participant }) => `Au tour ${tour}, la personne n° ${participant} n'est placée nulle part.`],
|
||||||
|
['IDENTIFIANT_REPETE', () => 'Une autre proposition porte déjà ce numéro.'],
|
||||||
|
['IDENTIFIANT_HORS_COMPTEUR', () => 'Ce numéro de proposition dépasse le compteur du fichier.'],
|
||||||
|
['PARTICIPANT_EXCLU', ({ participant }) => `La personne n° ${participant} est exclue depuis.`],
|
||||||
|
['PARTICIPANT_SUPPRIME', ({ participant }) => `La personne n° ${participant} a été supprimée depuis.`],
|
||||||
|
[
|
||||||
|
'PARTICIPANT_NON_PLACE',
|
||||||
|
({ participant }) => `La personne n° ${participant}, ajoutée ou réintégrée depuis, n'y est pas placée.`,
|
||||||
|
],
|
||||||
|
['TABLE_SUPPRIMEE', ({ table }) => `La table d'identifiant ${table} a été supprimée depuis.`],
|
||||||
|
['TABLE_AJOUTEE', ({ table }) => `La table d'identifiant ${table} a été ajoutée depuis.`],
|
||||||
|
[
|
||||||
|
'CAPACITE_CHANGEE',
|
||||||
|
({ table, avant, maintenant }) =>
|
||||||
|
`La table d'identifiant ${table} est passée de ${avant} à ${compte(maintenant, 'place', 'places')}.`,
|
||||||
|
],
|
||||||
|
['TOURS_CHANGES', ({ avant, maintenant }) => `Le nombre de tours est passé de ${avant} à ${maintenant}.`],
|
||||||
|
|
||||||
|
// --- Ce que l'ouverture annonce (§ 8.6, § 8.8, § 8.9, § 9) -----------------
|
||||||
|
['RETENU_FAUTIF', () => 'Le placement retenu se contredit : la version précédente du fichier peut le reprendre.'],
|
||||||
|
['JOURNAL_REPRIS', () => "Le dernier geste, inscrit à l'historique mais pas dans l'état, est repris."],
|
||||||
|
[
|
||||||
|
'JOURNAL_EN_AVANCE_DE',
|
||||||
|
({ n }) =>
|
||||||
|
`L'historique compte ${compte(n, 'geste', 'gestes')} de plus que l'état : ` +
|
||||||
|
"l'état du disque est affiché, et le prochain geste inscrit une reprise.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'JOURNAL_INCOMPLET',
|
||||||
|
({ etat, journal }) =>
|
||||||
|
`L'historique s'arrête à la révision ${journal}, l'état est à la révision ${etat} : ` +
|
||||||
|
'le prochain geste inscrit une reprise.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'JOURNAL_ECARTEES',
|
||||||
|
({ n }) =>
|
||||||
|
`${compte(n, 'entrée', 'entrées')} de l'historique ${n <= 1 ? 'est écartée' : 'sont écartées'}, ` +
|
||||||
|
'à partir de la première ligne illisible.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'JOURNAL_RECREE',
|
||||||
|
() =>
|
||||||
|
"Le fichier de l'historique avait disparu du dossier : il est réécrit en entier, " +
|
||||||
|
'tel que la séance le connaissait.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'JOURNAL_FORMAT_PLUS_RECENT',
|
||||||
|
({ format }) =>
|
||||||
|
`L'historique vient d'une version plus récente du logiciel (format ${format}) : ` +
|
||||||
|
"il ne se lit pas, et l'événement reste en lecture seule.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'JOURNAL_ILLISIBLE',
|
||||||
|
({ raison }, texte) =>
|
||||||
|
`L'historique à côté de ce fichier ne se lit pas. ${phraseDeRaison(raison, {}, texte)} ` +
|
||||||
|
"Il est laissé tel quel, et le prochain geste commence l'historique dans un nouveau fichier.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'JOURNAL_ETRANGER',
|
||||||
|
({ evenement }) =>
|
||||||
|
`L'historique à côté de ce fichier appartient à un autre événement (${evenement}) : il est ignoré.`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'PROPOSITIONS_FAUTIVES',
|
||||||
|
({ ids }) =>
|
||||||
|
ids.length <= 1
|
||||||
|
? `Une proposition incohérente est écartée (${numeros(ids)}).`
|
||||||
|
: `${ids.length} propositions incohérentes sont écartées (${numeros(ids)}).`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'PROPOSITIONS_MANQUANTES',
|
||||||
|
({ n }) =>
|
||||||
|
`${compte(n, "proposition que l'en-tête annonce manque", "propositions que l'en-tête annonce manquent")} ` +
|
||||||
|
'dans le fichier : celles qu\'il porte sont gardées.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'DERIVE',
|
||||||
|
(details) => {
|
||||||
|
const { texte, pluriel } = touchees(details);
|
||||||
|
return pluriel
|
||||||
|
? `${texte} ne décrivent plus la liste ou les tables actuelles : ils sont gardés, et rien n'est effacé.`
|
||||||
|
: `${texte} ne décrit plus la liste ou les tables actuelles : il est gardé, et rien n'est effacé.`;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// --- Le dossier de travail, les réglages locaux (§ 8.5, § 8.6, § 8.8) ------
|
||||||
|
['NON_PUBLIE', () => "Le dossier de l'exécutable n'est pas connu : les événements se rangent dans les Documents."],
|
||||||
|
[
|
||||||
|
'DONNEES_APPLICATIVES',
|
||||||
|
() => "L'exécutable est rangé sous les données applicatives : les événements se rangent dans les Documents.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'SONDE_ECHOUEE',
|
||||||
|
({ cause }, texte) =>
|
||||||
|
`Le dossier data à côté de l'exécutable refuse l'écriture${causeEntreParentheses(cause, texte)} : ` +
|
||||||
|
'les événements se rangent dans les Documents.',
|
||||||
|
],
|
||||||
|
// Une cause de la sonde, que les textes citent entre parenthèses.
|
||||||
|
['RELECTURE_DIFFERENTE', () => "le fichier d'essai relu ne porte pas ce qui vient d'y être écrit"],
|
||||||
|
// Une cause de l'OPFS : sous des paramètres régionaux qui ne sont pas
|
||||||
|
// UTF-8, le navigateur ne nomme pas un dossier ou un fichier hors ASCII.
|
||||||
|
[
|
||||||
|
'NOM_NON_CONVERTI',
|
||||||
|
() =>
|
||||||
|
"le navigateur ne sait pas nommer un dossier ou un fichier aux caractères accentués sous les réglages de langue du système",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'PORTABLE_NON_INSCRIPTIBLE',
|
||||||
|
({ racine, evenements, cause }, texte) => {
|
||||||
|
const porte =
|
||||||
|
evenements === null
|
||||||
|
? 'porte peut-être des événements'
|
||||||
|
: `porte ${compte(evenements, 'événement', 'événements')}`;
|
||||||
|
return `Le dossier ${cite(cheminDe(racine))} ${porte}, mais refuse l'écriture${causeEntreParentheses(cause, texte)}.`;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'DOCUMENTS_NON_INSCRIPTIBLE',
|
||||||
|
({ racine, cause }, texte) =>
|
||||||
|
`Le dossier de travail ${cite(cheminDe(racine))} refuse l'écriture${causeEntreParentheses(cause, texte)} : ` +
|
||||||
|
"aucun geste ne s'y enregistrerait.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'PLATEFORME_WEB',
|
||||||
|
() =>
|
||||||
|
"Sous la plateforme web, ni le renommage atomique ni le verrou n'existent : " +
|
||||||
|
'une panne peut laisser un fichier incomplet, et deux onglets peuvent écrire le même événement.',
|
||||||
|
],
|
||||||
|
// dossier, quand il est donné, nomme le dossier où la séance écrit
|
||||||
|
// désormais (ecrireAilleurs) ; sans lui, le dossier de travail.
|
||||||
|
[
|
||||||
|
'SUPPORT_AMOVIBLE',
|
||||||
|
({ dossier }) =>
|
||||||
|
`${typeof dossier === 'string' ? `Le dossier ${cite(dossier)}` : 'Le dossier de travail'} ` +
|
||||||
|
'est sur un support amovible : retirer le support pendant une écriture peut la laisser incomplète.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'REGLAGES_ILLISIBLES',
|
||||||
|
({ chemin, raison }, texte) => {
|
||||||
|
const pourquoi =
|
||||||
|
raison === 'JSON' || raison === 'FORME' ? texte(raison) : `La lecture est refusée (${raison}).`;
|
||||||
|
return `Les réglages locaux ${cite(chemin)} ne se lisent pas. ${pourquoi} Les valeurs par défaut s'appliquent.`;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
15
src/application/personnes.js
Normal file
15
src/application/personnes.js
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Le nom d'une personne tel que les libellés figés et le plan l'écrivent
|
||||||
|
// (§ 8.2) : la seule composition de ce nom dans l'application.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le prénom, puis le nom ; le nom seul quand le prénom est null.
|
||||||
|
*
|
||||||
|
* @param {{nom: string, prenom: string|null}} personne
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function nomAffiche({ nom, prenom }) {
|
||||||
|
return prenom === null ? nom : `${prenom} ${nom}`;
|
||||||
|
}
|
||||||
33
src/application/personnes.test.js
Normal file
33
src/application/personnes.test.js
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Épreuves du nom affiché (§ 8.2) : la seule composition du nom d'une
|
||||||
|
// personne, celle des libellés figés et du plan. Les noms sont inventés.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
|
import { nomAffiche as nomDesCommandes } from './commandes.js';
|
||||||
|
import { libelle } from './libelles.js';
|
||||||
|
import { nomAffiche } from './personnes.js';
|
||||||
|
|
||||||
|
describe('personnes : le nom affiché (§ 8.2)', () => {
|
||||||
|
test('prénom puis nom ; le nom seul quand le prénom est null', () => {
|
||||||
|
assert.equal(nomAffiche({ nom: 'Pervenche', prenom: 'Théo' }), 'Théo Pervenche');
|
||||||
|
assert.equal(nomAffiche({ nom: 'Grisaille', prenom: null }), 'Grisaille');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('les autres champs de la personne ne comptent pas', () => {
|
||||||
|
assert.equal(
|
||||||
|
nomAffiche({ id: 4, nom: 'Lacasse', prenom: 'Ondine', appartenance: 'Club des Merles', exclu: true }),
|
||||||
|
'Ondine Lacasse',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nomAffiche importé de commandes.js est celui de personnes.js', () => {
|
||||||
|
assert.equal(nomDesCommandes, nomAffiche);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un libellé figé qui nomme une personne la nomme par nomAffiche', () => {
|
||||||
|
const personne = nomAffiche({ nom: 'Ombrelle', prenom: 'Iris' });
|
||||||
|
assert.equal(libelle('ENTREE_AJOUTER_PARTICIPANT', { personne }), 'Ajouté Iris Ombrelle');
|
||||||
|
});
|
||||||
|
});
|
||||||
73
src/application/traduction.js
Normal file
73
src/application/traduction.js
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Le mécanisme de traduction (§ 14.6), unique pour l'application et les
|
||||||
|
// écrans. Il réunit des tables — chacune une Map clé → chaîne, ou clé →
|
||||||
|
// fonction (details, texte) → chaîne — et rend t(cle, details), le texte
|
||||||
|
// d'une clé. Une clé portée par deux tables lève à la création ; une clé
|
||||||
|
// absente lève RangeError, qui la nomme : l'écran ne montre jamais une clé
|
||||||
|
// brute ni un texte vide.
|
||||||
|
//
|
||||||
|
// texte(cle, details), que reçoit une fonction de table, compose une autre
|
||||||
|
// clé des mêmes tables, toujours en français ; texte.existe(cle) dit si une
|
||||||
|
// clé a son texte, pour un détail qui porte un code ou une cause du système.
|
||||||
|
// En langue témoin, t encadre de la marque son résultat entier, une fois :
|
||||||
|
// une composition par texte n'en ajoute aucune.
|
||||||
|
|
||||||
|
/** Les deux marques de la langue témoin, U+2045 et U+2046. */
|
||||||
|
export const MARQUE_TEMOIN = Object.freeze({ ouverture: '\u{2045}', fermeture: '\u{2046}' });
|
||||||
|
|
||||||
|
const LANGUES = ['fr', 'temoin'];
|
||||||
|
|
||||||
|
// Deux chaînes, comparées unité UTF-16 par unité.
|
||||||
|
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La traduction de ces tables, dans cette langue.
|
||||||
|
*
|
||||||
|
* @param {Array<Map<string, string|((details: Object, texte: Function) => string)>>} tables
|
||||||
|
* @param {{langue?: 'fr'|'temoin'}} [options]
|
||||||
|
* @returns {{t: (cle: string, details?: Object) => string, cles: () => string[], langue: string}}
|
||||||
|
* t lève RangeError sur une clé absente, TypeError quand une fonction de
|
||||||
|
* table ne rend pas une chaîne ; cles() rend toutes les clés, triées par
|
||||||
|
* unités UTF-16
|
||||||
|
* @throws {RangeError} une clé de deux tables, une langue inconnue
|
||||||
|
* @throws {TypeError} une table qui n'est pas une Map, une entrée ni chaîne
|
||||||
|
* ni fonction
|
||||||
|
*/
|
||||||
|
export function creerTraduction(tables, { langue = 'fr' } = {}) {
|
||||||
|
if (!LANGUES.includes(langue)) throw new RangeError(`traduction : langue inconnue ${JSON.stringify(langue)}`);
|
||||||
|
const entrees = new Map();
|
||||||
|
for (const table of tables) {
|
||||||
|
if (!(table instanceof Map)) throw new TypeError('traduction : chaque table est une Map');
|
||||||
|
for (const [cle, entree] of table) {
|
||||||
|
if (entrees.has(cle)) {
|
||||||
|
throw new RangeError(`traduction : la clé ${JSON.stringify(cle)} est portée par deux tables`);
|
||||||
|
}
|
||||||
|
if (typeof entree !== 'string' && typeof entree !== 'function') {
|
||||||
|
throw new TypeError(`traduction : ${JSON.stringify(cle)} n'est ni une chaîne ni une fonction`);
|
||||||
|
}
|
||||||
|
entrees.set(cle, entree);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le texte français d'une clé : la chaîne, ou ce que rend la fonction de
|
||||||
|
// ses détails, un objet vide quand ils manquent.
|
||||||
|
function texte(cle, details = {}) {
|
||||||
|
const entree = entrees.get(cle);
|
||||||
|
if (entree === undefined) throw new RangeError(`traduction : clé inconnue ${JSON.stringify(cle)}`);
|
||||||
|
if (typeof entree === 'string') return entree;
|
||||||
|
const rendu = entree(details ?? {}, texte);
|
||||||
|
if (typeof rendu !== 'string') throw new TypeError(`traduction : ${JSON.stringify(cle)} ne rend pas une chaîne`);
|
||||||
|
return rendu;
|
||||||
|
}
|
||||||
|
texte.existe = (cle) => entrees.has(cle);
|
||||||
|
|
||||||
|
const { ouverture, fermeture } = MARQUE_TEMOIN;
|
||||||
|
const t =
|
||||||
|
langue === 'temoin'
|
||||||
|
? (cle, details) => `${ouverture}${texte(cle, details)}${fermeture}`
|
||||||
|
: (cle, details) => texte(cle, details);
|
||||||
|
const triees = Object.freeze([...entrees.keys()].sort(comparerTextes));
|
||||||
|
return { t, cles: () => [...triees], langue };
|
||||||
|
}
|
||||||
111
src/application/traduction.test.js
Normal file
111
src/application/traduction.test.js
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Épreuves du mécanisme de traduction (§ 14.6) : une clé absente lève et se
|
||||||
|
// nomme, une clé de deux tables lève à la création, les clés s'énumèrent
|
||||||
|
// triées, une entrée en compose une autre, et la langue témoin encadre le
|
||||||
|
// résultat entier de t d'une seule paire de marques. Les tables d'épreuve sont
|
||||||
|
// inventées.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
|
import { MARQUE_TEMOIN, creerTraduction } from './traduction.js';
|
||||||
|
|
||||||
|
const { ouverture: O, fermeture: F } = MARQUE_TEMOIN;
|
||||||
|
|
||||||
|
const TABLE_A = new Map([
|
||||||
|
['essai.vide', '\u{2014}'],
|
||||||
|
['essai.salut', ({ nom }) => `Bonjour ${nom}`],
|
||||||
|
['essai.compose', ({ nom }, texte) => `${texte('essai.salut', { nom })}, ${texte('essai.vide')}`],
|
||||||
|
]);
|
||||||
|
const TABLE_B = new Map([
|
||||||
|
['AUTRE_CODE', () => 'Un code d’essai.'],
|
||||||
|
['autre.libelle', 'Un libellé'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
describe('traduction : le mécanisme (§ 14.6)', () => {
|
||||||
|
test('les deux marques de la langue témoin sont U+2045 et U+2046, et figées', () => {
|
||||||
|
assert.equal(O, '\u{2045}');
|
||||||
|
assert.equal(F, '\u{2046}');
|
||||||
|
assert.ok(Object.isFrozen(MARQUE_TEMOIN));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une clé absente lève RangeError, et le message nomme la clé ; jamais la clé brute, jamais une chaîne vide', () => {
|
||||||
|
const { t } = creerTraduction([TABLE_A]);
|
||||||
|
assert.throws(() => t('essai.inconnue'), (erreur) => {
|
||||||
|
assert.ok(erreur instanceof RangeError);
|
||||||
|
assert.match(erreur.message, /essai\.inconnue/);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
assert.throws(() => t(undefined), RangeError);
|
||||||
|
assert.throws(() => t('constructor'), RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une clé portée par deux tables lève à la création, et se nomme', () => {
|
||||||
|
const doublon = new Map([['essai.salut', 'Salut']]);
|
||||||
|
assert.throws(() => creerTraduction([TABLE_A, doublon]), (erreur) => {
|
||||||
|
assert.ok(erreur instanceof RangeError);
|
||||||
|
assert.match(erreur.message, /essai\.salut/);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cles() énumère toutes les clés des tables, triées par unités UTF-16', () => {
|
||||||
|
const { cles } = creerTraduction([TABLE_A, TABLE_B]);
|
||||||
|
const attendues = ['AUTRE_CODE', 'autre.libelle', 'essai.compose', 'essai.salut', 'essai.vide'];
|
||||||
|
assert.equal(cles().length, 5);
|
||||||
|
assert.deepEqual(cles(), attendues);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une chaîne se rend telle quelle, une fonction reçoit ses détails, et une entrée en compose une autre par texte', () => {
|
||||||
|
const { t, langue } = creerTraduction([TABLE_A, TABLE_B]);
|
||||||
|
assert.equal(langue, 'fr');
|
||||||
|
assert.equal(t('autre.libelle'), 'Un libellé');
|
||||||
|
assert.equal(t('essai.salut', { nom: 'Iris Ombrelle' }), 'Bonjour Iris Ombrelle');
|
||||||
|
assert.equal(t('essai.compose', { nom: 'Théo Pervenche' }), 'Bonjour Théo Pervenche, \u{2014}');
|
||||||
|
assert.equal(t('AUTRE_CODE'), 'Un code d’essai.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une fonction appelée sans détails en reçoit un objet vide ; une entrée qui ne rend pas une chaîne lève", () => {
|
||||||
|
const table = new Map([
|
||||||
|
['essai.details', (details) => `${Object.keys(details).length}`],
|
||||||
|
['essai.nombre', () => 3],
|
||||||
|
]);
|
||||||
|
const { t } = creerTraduction([table]);
|
||||||
|
assert.equal(t('essai.details'), '0');
|
||||||
|
assert.equal(t('essai.details', null), '0');
|
||||||
|
assert.throws(() => t('essai.nombre'), TypeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une table qui n'est pas une Map, une entrée ni chaîne ni fonction, une langue inconnue lèvent à la création", () => {
|
||||||
|
assert.throws(() => creerTraduction([{ 'essai.objet': 'x' }]), TypeError);
|
||||||
|
assert.throws(() => creerTraduction([new Map([['essai.nombre', 4]])]), TypeError);
|
||||||
|
assert.throws(() => creerTraduction([TABLE_A], { langue: 'en' }), RangeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('traduction : la langue témoin (§ 14.6)', () => {
|
||||||
|
test('t encadre son résultat de la marque, une fois', () => {
|
||||||
|
const { t, langue } = creerTraduction([TABLE_A, TABLE_B], { langue: 'temoin' });
|
||||||
|
assert.equal(langue, 'temoin');
|
||||||
|
assert.equal(t('essai.vide'), `${O}\u{2014}${F}`);
|
||||||
|
assert.equal(t('autre.libelle'), `${O}Un libellé${F}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une fonction de détails est décorée sur son résultat entier', () => {
|
||||||
|
const { t } = creerTraduction([TABLE_A], { langue: 'temoin' });
|
||||||
|
assert.equal(t('essai.salut', { nom: 'Ondine Lacasse' }), `${O}Bonjour Ondine Lacasse${F}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une composition ne porte qu'une paire de marques : texte compose en français", () => {
|
||||||
|
const { t } = creerTraduction([TABLE_A], { langue: 'temoin' });
|
||||||
|
const rendu = t('essai.compose', { nom: 'Grisaille' });
|
||||||
|
assert.equal(rendu, `${O}Bonjour Grisaille, \u{2014}${F}`);
|
||||||
|
assert.equal(rendu.split(O).length - 1, 1);
|
||||||
|
assert.equal(rendu.split(F).length - 1, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une clé absente lève en langue témoin comme en français', () => {
|
||||||
|
const { t } = creerTraduction([TABLE_A], { langue: 'temoin' });
|
||||||
|
assert.throws(() => t('essai.inconnue'), RangeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
158
src/geometrie/constantes.js
Normal file
158
src/geometrie/constantes.js
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Le registre des constantes mesurées (§ 14.1, § 19.10). Toute dimension qui
|
||||||
|
// dépend du rendu ou du geste se relève, jamais ne se déduit : chaque entrée
|
||||||
|
// dit ce qu'elle mesure, pourquoi elle existe, sur quelle configuration elle
|
||||||
|
// se relève, qui la relève et comment. Tant que son propriétaire ne l'a pas
|
||||||
|
// relevée, une constante porte sa valeur de départ et se déclare défaut de
|
||||||
|
// travail ; il écrit ici la valeur relevée, passe le statut à « mesuree » et
|
||||||
|
// complète la configuration. Le banc de mesure lit ce tableau pour en
|
||||||
|
// engendrer la région de la documentation (§ 19.10).
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Constante
|
||||||
|
* @property {string} nom
|
||||||
|
* @property {number|null} valeur null pour une constante relevée à l'exécution
|
||||||
|
* @property {string} unite
|
||||||
|
* @property {string} mesure ce qu'elle mesure
|
||||||
|
* @property {string} raison pourquoi elle existe
|
||||||
|
* @property {string} configuration sur quoi elle se relève : machine, cadre, configuration
|
||||||
|
* @property {'banc'|'node'|'manuel'|'execution'} proprietaire
|
||||||
|
* @property {'mesuree'|'defautDeTravail'} statut
|
||||||
|
* @property {string} protocole comment le propriétaire la relève, et où il l'écrit
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PROPRIETAIRES = new Set(['banc', 'node', 'manuel', 'execution']);
|
||||||
|
const STATUTS = new Set(['mesuree', 'defautDeTravail']);
|
||||||
|
|
||||||
|
/** @type {ReadonlyArray<Readonly<Constante>>} */
|
||||||
|
export const CONSTANTES = Object.freeze(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
nom: 'SEUIL_GLISSER_PX',
|
||||||
|
valeur: 4,
|
||||||
|
unite: 'px',
|
||||||
|
mesure: "déplacement du pointeur, entre pointerdown et pointerup, au-delà duquel un appui devient un glissement",
|
||||||
|
raison: "un clic délibéré bouge de quelques pixels : sous ce seuil, il resterait un clic et ne déplacerait rien (§ 8.4)",
|
||||||
|
configuration: 'pavé tactile de la machine de référence',
|
||||||
|
proprietaire: 'manuel',
|
||||||
|
statut: 'defautDeTravail',
|
||||||
|
protocole:
|
||||||
|
'vingt clics délibérés au pavé tactile de la machine de référence ; le plus grand déplacement entre ' +
|
||||||
|
'pointerdown et pointerup, × 1,5, écrit dans ce registre',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nom: 'DUREE_PRESSION_LONGUE_MS',
|
||||||
|
valeur: 500,
|
||||||
|
unite: 'ms',
|
||||||
|
mesure: "durée d'un appui immobile au-delà de laquelle il ouvre le menu d'une personne",
|
||||||
|
raison: "un appui bref ne doit jamais ouvrir le menu, ni une pression voulue se faire attendre (§ 7.3)",
|
||||||
|
configuration: 'écran tactile de la machine de référence',
|
||||||
|
proprietaire: 'manuel',
|
||||||
|
statut: 'defautDeTravail',
|
||||||
|
protocole:
|
||||||
|
"à l'écran tactile de la machine de référence, la plus courte durée qu'aucun appui bref ne déclenche, " +
|
||||||
|
'sur dix essais, écrite dans ce registre',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nom: 'SEUIL_SIEGES_LISIBLES',
|
||||||
|
valeur: 16,
|
||||||
|
unite: 'places',
|
||||||
|
mesure: "plus grand nombre de places d'une table dont la liste de noms reste lisible",
|
||||||
|
raison: "au-delà, le dessin d'une table cesse d'être lisible, et l'application le signale (§ 6.2)",
|
||||||
|
configuration: 'banc de mesure, cadre de 1 366 × 700 px',
|
||||||
|
proprietaire: 'banc',
|
||||||
|
statut: 'defautDeTravail',
|
||||||
|
protocole:
|
||||||
|
'banc de mesure, cadre de 1 366 × 700 px : le plus grand nombre de places dont la liste, à la hauteur ' +
|
||||||
|
"de ligne mesurée, tient dans la hauteur du cadre au zoom ajusté d'une table seule ; écrit dans ce registre",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nom: 'K_LISTES',
|
||||||
|
valeur: 0.3,
|
||||||
|
unite: 'px/cm',
|
||||||
|
mesure: 'échelle sous laquelle le plan retire les listes de noms',
|
||||||
|
raison: "sous elle, dessiner les listes coûte plus d'une image par déplacement de vue (§ 17, point 6)",
|
||||||
|
configuration: 'banc de mesure, grande démonstration, 33 tables avec listes',
|
||||||
|
proprietaire: 'banc',
|
||||||
|
statut: 'defautDeTravail',
|
||||||
|
protocole:
|
||||||
|
'banc de mesure : temps par image pendant un déplacement de vue sur la grande démonstration, 33 tables ' +
|
||||||
|
'avec listes, à plusieurs k ; le plus petit k qui tient le critère de temps par image, écrit dans ce registre',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nom: 'LARGEUR_BLOC_NOMS_CM',
|
||||||
|
valeur: 180,
|
||||||
|
unite: 'cm',
|
||||||
|
mesure: "largeur, dans le dessin, du bloc de noms d'une table, où un nom trop long s'abrège",
|
||||||
|
raison: 'un nom tient sur une ligne du plan sans recouvrir la table voisine (§ 7.4)',
|
||||||
|
configuration: 'grande démonstration au zoom par défaut',
|
||||||
|
proprietaire: 'manuel',
|
||||||
|
statut: 'defautDeTravail',
|
||||||
|
protocole:
|
||||||
|
"relecture de la grande démonstration au zoom par défaut par une personne qui n'a pas écrit le " +
|
||||||
|
'logiciel (§ 19.11) ; la largeur retenue, écrite dans ce registre',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nom: 'HAUTEUR_LIGNE',
|
||||||
|
valeur: null,
|
||||||
|
unite: 'cm',
|
||||||
|
mesure: "hauteur d'une ligne d'une liste de noms, dans le dessin",
|
||||||
|
raison: 'deux lignes ne se chevauchent pas, quels que soient les accents et les jambages (§ 7.4)',
|
||||||
|
configuration: 'police calculée des listes, relevée à chaque montage du plan',
|
||||||
|
proprietaire: 'execution',
|
||||||
|
statut: 'mesuree',
|
||||||
|
protocole:
|
||||||
|
'mesurée à chaque montage du plan : measureText du gabarit « Ôjgq Æ W » sur la police calculée des ' +
|
||||||
|
"listes ; aucune valeur n'est écrite dans ce registre",
|
||||||
|
},
|
||||||
|
].map((entree) => Object.freeze(entree)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Champs textuels d'une entrée, qui ne peuvent être vides.
|
||||||
|
const CHAMPS_TEXTE = ['unite', 'mesure', 'raison', 'configuration'];
|
||||||
|
|
||||||
|
const estTexte = (valeur) => typeof valeur === 'string' && valeur.length > 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La valeur de la constante nommée ; null pour une constante relevée à
|
||||||
|
* l'exécution. Lève RangeError sur un nom absent du registre.
|
||||||
|
* @param {string} nom
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
export function constante(nom) {
|
||||||
|
const entree = CONSTANTES.find((c) => c.nom === nom);
|
||||||
|
if (entree === undefined) throw new RangeError(`constante inconnue : ${String(nom)}`);
|
||||||
|
return entree.valeur;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les fautes d'un registre, [] quand chaque entrée est complète : une par
|
||||||
|
* champ fautif, dans l'ordre des entrées, puis dans l'ordre des champs du
|
||||||
|
* type Constante. Un nom vide, ou déjà porté par une entrée précédente, est
|
||||||
|
* une faute ; la valeur est un nombre fini, et null pour une constante
|
||||||
|
* relevée à l'exécution seulement ; les champs textuels ne sont pas vides ;
|
||||||
|
* propriétaire et statut prennent une valeur de leur liste.
|
||||||
|
* @param {ReadonlyArray<Constante>} liste
|
||||||
|
* @returns {Array<{nom: string, champ: string}>}
|
||||||
|
*/
|
||||||
|
export function verifierRegistre(liste) {
|
||||||
|
const vus = new Set();
|
||||||
|
const fautes = [];
|
||||||
|
for (const entree of liste) {
|
||||||
|
const nom = entree.nom;
|
||||||
|
const faute = (champ) => fautes.push({ nom, champ });
|
||||||
|
if (!estTexte(nom) || vus.has(nom)) faute('nom');
|
||||||
|
vus.add(nom);
|
||||||
|
const execution = entree.proprietaire === 'execution';
|
||||||
|
if (execution ? entree.valeur !== null : !Number.isFinite(entree.valeur)) faute('valeur');
|
||||||
|
for (const champ of CHAMPS_TEXTE) {
|
||||||
|
if (!estTexte(entree[champ])) faute(champ);
|
||||||
|
}
|
||||||
|
if (!PROPRIETAIRES.has(entree.proprietaire)) faute('proprietaire');
|
||||||
|
if (!STATUTS.has(entree.statut)) faute('statut');
|
||||||
|
if (!estTexte(entree.protocole)) faute('protocole');
|
||||||
|
}
|
||||||
|
return fautes;
|
||||||
|
}
|
||||||
96
src/geometrie/constantes.test.js
Normal file
96
src/geometrie/constantes.test.js
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Épreuves du registre des constantes mesurées (§ 14.1, § 19.10) : les six
|
||||||
|
// entrées du contrat de données, chacune complète, et la vérification qui
|
||||||
|
// nomme l'entrée et le champ d'une faute.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
|
import { CONSTANTES, constante, verifierRegistre } from './constantes.js';
|
||||||
|
|
||||||
|
// Le contrat de données : nom, valeur de départ, unité, propriétaire.
|
||||||
|
const CONTRAT = [
|
||||||
|
['SEUIL_GLISSER_PX', 4, 'px', 'manuel'],
|
||||||
|
['DUREE_PRESSION_LONGUE_MS', 500, 'ms', 'manuel'],
|
||||||
|
['SEUIL_SIEGES_LISIBLES', 16, 'places', 'banc'],
|
||||||
|
['K_LISTES', 0.3, 'px/cm', 'banc'],
|
||||||
|
['LARGEUR_BLOC_NOMS_CM', 180, 'cm', 'manuel'],
|
||||||
|
['HAUTEUR_LIGNE', null, 'cm', 'execution'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Une copie du registre, entrée par entrée, que l'épreuve peut altérer.
|
||||||
|
const copie = () => CONSTANTES.map((entree) => ({ ...entree }));
|
||||||
|
|
||||||
|
describe('constantes : le registre (§ 14.1, § 19.10)', () => {
|
||||||
|
test('le registre porte les six entrées du contrat, dans son ordre, chacune complète', () => {
|
||||||
|
assert.equal(CONSTANTES.length, 6);
|
||||||
|
assert.deepEqual(
|
||||||
|
CONSTANTES.map(({ nom, valeur, unite, proprietaire }) => [nom, valeur, unite, proprietaire]),
|
||||||
|
CONTRAT,
|
||||||
|
);
|
||||||
|
assert.deepEqual(verifierRegistre(CONSTANTES), []);
|
||||||
|
for (const entree of CONSTANTES) {
|
||||||
|
assert.ok(entree.protocole.length > 0 && entree.mesure.length > 0 && entree.raison.length > 0, entree.nom);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tant que son propriétaire ne l\'a pas relevée, une constante se déclare défaut de travail', () => {
|
||||||
|
const statuts = CONSTANTES.map(({ nom, statut }) => [nom, statut]);
|
||||||
|
assert.deepEqual(statuts.slice(0, 5), CONTRAT.slice(0, 5).map(([nom]) => [nom, 'defautDeTravail']));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('constante rend la valeur par le nom ; un nom inconnu lève', () => {
|
||||||
|
assert.equal(constante('SEUIL_GLISSER_PX'), 4);
|
||||||
|
assert.equal(constante('K_LISTES'), 0.3);
|
||||||
|
assert.equal(constante('LARGEUR_BLOC_NOMS_CM'), 180);
|
||||||
|
assert.equal(constante('HAUTEUR_LIGNE'), null);
|
||||||
|
assert.throws(() => constante('INCONNUE'), RangeError);
|
||||||
|
assert.throws(() => constante('nom'), RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le registre et ses entrées sont figés', () => {
|
||||||
|
assert.ok(Object.isFrozen(CONSTANTES));
|
||||||
|
assert.ok(CONSTANTES.every((entree) => Object.isFrozen(entree)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verifierRegistre nomme l'entrée et le champ d'une faute : propriétaire, configuration ou statut perdu", () => {
|
||||||
|
for (const champ of ['proprietaire', 'configuration', 'statut']) {
|
||||||
|
const liste = copie();
|
||||||
|
delete liste[2][champ];
|
||||||
|
assert.deepEqual(verifierRegistre(liste), [{ nom: 'SEUIL_SIEGES_LISIBLES', champ }], champ);
|
||||||
|
}
|
||||||
|
const vide = copie();
|
||||||
|
vide[0].configuration = '';
|
||||||
|
vide[4].proprietaire = 'personne';
|
||||||
|
vide[1].statut = 'mesure';
|
||||||
|
assert.deepEqual(verifierRegistre(vide), [
|
||||||
|
{ nom: 'SEUIL_GLISSER_PX', champ: 'configuration' },
|
||||||
|
{ nom: 'DUREE_PRESSION_LONGUE_MS', champ: 'statut' },
|
||||||
|
{ nom: 'LARGEUR_BLOC_NOMS_CM', champ: 'proprietaire' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verifierRegistre relève aussi une valeur absente hors de l'exécution, un texte vide, un nom répété", () => {
|
||||||
|
const liste = copie();
|
||||||
|
liste[0].valeur = null;
|
||||||
|
liste[1].valeur = Number.NaN;
|
||||||
|
liste[2].protocole = '';
|
||||||
|
liste[3].unite = undefined;
|
||||||
|
liste[4].mesure = 3;
|
||||||
|
liste[5].raison = '';
|
||||||
|
liste.push({ ...liste[3], unite: 'px/cm' });
|
||||||
|
assert.deepEqual(verifierRegistre(liste), [
|
||||||
|
{ nom: 'SEUIL_GLISSER_PX', champ: 'valeur' },
|
||||||
|
{ nom: 'DUREE_PRESSION_LONGUE_MS', champ: 'valeur' },
|
||||||
|
{ nom: 'SEUIL_SIEGES_LISIBLES', champ: 'protocole' },
|
||||||
|
{ nom: 'K_LISTES', champ: 'unite' },
|
||||||
|
{ nom: 'LARGEUR_BLOC_NOMS_CM', champ: 'mesure' },
|
||||||
|
{ nom: 'HAUTEUR_LIGNE', champ: 'raison' },
|
||||||
|
{ nom: 'K_LISTES', champ: 'nom' },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(verifierRegistre([{ ...liste[0], nom: '', valeur: 4 }]), [{ nom: '', champ: 'nom' }]);
|
||||||
|
// Une constante relevée à l'exécution n'a pas de valeur dans le registre :
|
||||||
|
// un nombre y serait le relevé d'une autre machine.
|
||||||
|
assert.deepEqual(verifierRegistre([{ ...CONSTANTES[5], valeur: 21.5 }]), [{ nom: 'HAUTEUR_LIGNE', champ: 'valeur' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
75
src/geometrie/designation.js
Normal file
75
src/geometrie/designation.js
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// La désignation d'un siège (§ 7.3, § 14.10) : ce que contient un point de
|
||||||
|
// dessin, lu sur les géométries de tables.js, celles mêmes que le dessin pose.
|
||||||
|
// Le dépôt d'un glissement ne cherche pas sa cible dans le DOM : il convertit
|
||||||
|
// le point en coordonnées de dessin et le passe à designer.
|
||||||
|
|
||||||
|
/** Taille d'écran minimale d'une cible de siège, en px (§ 7.3). */
|
||||||
|
export const CIBLE_MIN_PX = 24;
|
||||||
|
|
||||||
|
// Vrai quand le point est dans le disque (ronde) ou le carré aux côtés
|
||||||
|
// parallèles aux axes (carrée) de demi-taille donnée, centré sur la table,
|
||||||
|
// bord compris.
|
||||||
|
function dans(point, { centre, forme }, demiTaille) {
|
||||||
|
const dx = point.x - centre.x;
|
||||||
|
const dy = point.y - centre.y;
|
||||||
|
return forme === 'ronde' ? Math.hypot(dx, dy) <= demiTaille : Math.max(Math.abs(dx), Math.abs(dy)) <= demiTaille;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Régime des cibles d'une table au zoom k : 'sieges' quand k × pas ≥
|
||||||
|
* CIBLE_MIN_PX, 'table' sinon. Le seuil se décide table par table, sur le
|
||||||
|
* pas de chacune.
|
||||||
|
*
|
||||||
|
* @param {number} k px/cm
|
||||||
|
* @param {import('./tables.js').GeometrieTable} geometrie
|
||||||
|
* @returns {'sieges'|'table'}
|
||||||
|
*/
|
||||||
|
export function regimeCibles(k, geometrie) {
|
||||||
|
return k * geometrie.pas >= CIBLE_MIN_PX ? 'sieges' : 'table';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ce que désigne un point de dessin au zoom k.
|
||||||
|
*
|
||||||
|
* Les tables s'examinent de la dernière reçue à la première, l'ordre inverse
|
||||||
|
* de la peinture : la première dont la surface peinte contient le point
|
||||||
|
* décide. En régime table, la surface est l'emprise — disque d'une ronde,
|
||||||
|
* carré d'une carrée — et la table est désignée. En régime sièges, elle est
|
||||||
|
* le plateau et les chaises : un point dans une chaise désigne son siège ;
|
||||||
|
* un point sur le plateau ne désigne rien, et cache les chaises des tables
|
||||||
|
* peintes dessous ; entre les chaises, le point passe aux tables de dessous.
|
||||||
|
* Deux chaises d'une même table ne se recouvrent pas.
|
||||||
|
*
|
||||||
|
* @param {{x: number, y: number}} point cm
|
||||||
|
* @param {import('./tables.js').GeometrieTable[]} geometries dans l'ordre du dessin
|
||||||
|
* @param {number} k px/cm
|
||||||
|
* @returns {{table: number, siege: number}|{table: number}|null}
|
||||||
|
*/
|
||||||
|
export function designer(point, geometries, k) {
|
||||||
|
for (let i = geometries.length - 1; i >= 0; i -= 1) {
|
||||||
|
const geometrie = geometries[i];
|
||||||
|
if (regimeCibles(k, geometrie) === 'table') {
|
||||||
|
if (dans(point, geometrie, geometrie.rayonEmprise)) return { table: geometrie.id };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const chaise = geometrie.chaises.find((c) => Math.hypot(point.x - c.x, point.y - c.y) <= c.rayon);
|
||||||
|
if (chaise !== undefined) return { table: geometrie.id, siege: chaise.siege };
|
||||||
|
if (dans(point, geometrie, geometrie.demiTaille)) return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrai quand le point n'est dans l'emprise — disque ou carré — d'aucune
|
||||||
|
* table : le sol, où un dépôt retire la personne du tour.
|
||||||
|
*
|
||||||
|
* @param {{x: number, y: number}} point cm
|
||||||
|
* @param {import('./tables.js').GeometrieTable[]} geometries
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function surLeSol(point, geometries) {
|
||||||
|
return !geometries.some((geometrie) => dans(point, geometrie, geometrie.rayonEmprise));
|
||||||
|
}
|
||||||
188
src/geometrie/designation.test.js
Normal file
188
src/geometrie/designation.test.js
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Épreuves de la désignation (§ 7.3, § 14.10) : le régime des cibles, décidé
|
||||||
|
// table par table sur le pas de chacune, et le siège que désigne un point de
|
||||||
|
// dessin, qui est celui que geometrieTable pose — sur les quatre
|
||||||
|
// démonstrations livrées et sur des carrées, à un zoom où les sièges sont
|
||||||
|
// des cibles et à un zoom où la table l'est.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
|
import { LIVREES } from '../demo/livrees.js';
|
||||||
|
import { analyser, capacite } from '../stockage/document.js';
|
||||||
|
import { CIBLE_MIN_PX, designer, regimeCibles, surLeSol } from './designation.js';
|
||||||
|
import { englobant, geometrieTable } from './tables.js';
|
||||||
|
|
||||||
|
const table = (id, forme, x, y) => ({ id, numero: id, sieges: null, forme, position: { x, y } });
|
||||||
|
|
||||||
|
const milieu = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
|
||||||
|
|
||||||
|
describe('regimeCibles (§ 7.3)', () => {
|
||||||
|
test('le seuil est de 24 px', () => {
|
||||||
|
assert.equal(CIBLE_MIN_PX, 24);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ronde de 8, pas 60 : 0,40 px/cm et au-dessus, les sièges', () => {
|
||||||
|
const g = geometrieTable(table(1, 'ronde', 0, 0), 8);
|
||||||
|
assert.equal(regimeCibles(0.41, g), 'sieges');
|
||||||
|
assert.equal(regimeCibles(0.4, g), 'sieges');
|
||||||
|
assert.equal(regimeCibles(0.39, g), 'table');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ronde de 3, pas 70π/3 : bascule entre 0,33 et 0,32', () => {
|
||||||
|
const g = geometrieTable(table(1, 'ronde', 0, 0), 3);
|
||||||
|
assert.equal(regimeCibles(0.33, g), 'sieges');
|
||||||
|
assert.equal(regimeCibles(0.32, g), 'table');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('carrée de 3, pas 70 : bascule entre 0,35 et 0,34', () => {
|
||||||
|
const g = geometrieTable(table(1, 'carree', 0, 0), 3);
|
||||||
|
assert.equal(regimeCibles(0.35, g), 'sieges');
|
||||||
|
assert.equal(regimeCibles(0.34, g), 'table');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ronde de 7, pas 60 exactement : sièges à 0,40', () => {
|
||||||
|
assert.equal(regimeCibles(0.4, geometrieTable(table(1, 'ronde', 0, 0), 7)), 'sieges');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('l’emprise d’une carrée est un carré', () => {
|
||||||
|
// Carrée de 12 au centre (0, 0) : côté 180, emprise 90 + 44 = 134 ; le
|
||||||
|
// siège 6, dernier du côté droit, est en (112, 60).
|
||||||
|
const geometries = [geometrieTable(table(7, 'carree', 0, 0), 12)];
|
||||||
|
|
||||||
|
test('(125, 125) : hors du plateau et des chaises, dans le coin de l’emprise', () => {
|
||||||
|
const point = { x: 125, y: 125 };
|
||||||
|
assert.deepEqual(designer(point, geometries, 0.2), { table: 7 });
|
||||||
|
assert.equal(designer(point, geometries, 1), null);
|
||||||
|
assert.equal(surLeSol(point, geometries), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('(130, 60) : dans la chaise du siège 6, à 143 cm du centre', () => {
|
||||||
|
const point = { x: 130, y: 60 };
|
||||||
|
assert.deepEqual(designer(point, geometries, 0.2), { table: 7 });
|
||||||
|
assert.deepEqual(designer(point, geometries, 1), { table: 7, siege: 6 });
|
||||||
|
assert.equal(surLeSol(point, geometries), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('(140, 0) est le sol', () => {
|
||||||
|
const point = { x: 140, y: 0 };
|
||||||
|
assert.equal(designer(point, geometries, 0.2), null);
|
||||||
|
assert.equal(designer(point, geometries, 1), null);
|
||||||
|
assert.equal(surLeSol(point, geometries), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Les géométries de chaque démonstration livrée, telles que le dessin les
|
||||||
|
// pose, puis trois carrées de 3, 8 et 12 : les démonstrations n'ont que des
|
||||||
|
// rondes.
|
||||||
|
const JEUX = [
|
||||||
|
...LIVREES.map(({ cle, texte }) => {
|
||||||
|
const { charge } = analyser(texte);
|
||||||
|
return { nom: cle, geometries: charge.tables.map((t) => geometrieTable(t, capacite(charge, t))) };
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
nom: 'carrées',
|
||||||
|
geometries: [
|
||||||
|
geometrieTable(table(1, 'carree', 0, 0), 3),
|
||||||
|
geometrieTable(table(2, 'carree', 1000, 0), 8),
|
||||||
|
geometrieTable(table(3, 'carree', 2000, 0), 12),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('le siège désigné est celui que le dessin pose (§ 7.3, § 14.10)', () => {
|
||||||
|
const ATTENDUES = { grande: 260, 'grande-sans-exception': 264, petite: 12, 'petite-conflit': 12, 'carrées': 23 };
|
||||||
|
|
||||||
|
test('cinq jeux, dans l’ordre', () => {
|
||||||
|
assert.deepEqual(JEUX.map(({ nom }) => nom), ['grande', 'grande-sans-exception', 'petite', 'petite-conflit', 'carrées']);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const { nom, geometries } of JEUX) {
|
||||||
|
test(`${nom}, k = 1 : chaque chaise rend sa table et son siège`, () => {
|
||||||
|
let chaises = 0;
|
||||||
|
for (const g of geometries) {
|
||||||
|
assert.equal(regimeCibles(1, g), 'sieges');
|
||||||
|
for (const chaise of g.chaises) {
|
||||||
|
assert.deepEqual(designer(chaise, geometries, 1), { table: g.id, siege: chaise.siege });
|
||||||
|
chaises += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(ATTENDUES[nom] > 0);
|
||||||
|
assert.equal(chaises, ATTENDUES[nom]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${nom}, k = 1 : ni le milieu de deux chaises voisines, ni le centre d’une table ne désigne rien`, () => {
|
||||||
|
let milieux = 0;
|
||||||
|
for (const g of geometries) {
|
||||||
|
const n = g.chaises.length;
|
||||||
|
for (let i = 0; i < n; i += 1) {
|
||||||
|
const point = milieu(g.chaises[i], g.chaises[(i + 1) % n]);
|
||||||
|
assert.equal(designer(point, geometries, 1), null, `table ${g.id}, sièges ${i + 1} et ${((i + 1) % n) + 1}`);
|
||||||
|
assert.equal(surLeSol(point, geometries), false);
|
||||||
|
milieux += 1;
|
||||||
|
}
|
||||||
|
assert.equal(designer(g.centre, geometries, 1), null, `centre de la table ${g.id}`);
|
||||||
|
assert.equal(surLeSol(g.centre, geometries), false);
|
||||||
|
}
|
||||||
|
assert.equal(milieux, ATTENDUES[nom]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${nom} : un point à 10 m de tout est le sol`, () => {
|
||||||
|
const e = englobant(geometries);
|
||||||
|
const loin = { x: e.maxX + 1000, y: e.maxY + 1000 };
|
||||||
|
assert.equal(surLeSol(loin, geometries), true);
|
||||||
|
assert.equal(designer(loin, geometries, 1), null);
|
||||||
|
assert.equal(designer(loin, geometries, 0.2), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${nom}, k = 0,2 : chaque chaise, chaque milieu et chaque centre rendent la table, jamais un siège`, () => {
|
||||||
|
let points = 0;
|
||||||
|
for (const g of geometries) {
|
||||||
|
assert.equal(regimeCibles(0.2, g), 'table');
|
||||||
|
const n = g.chaises.length;
|
||||||
|
const candidats = [g.centre, ...g.chaises, ...g.chaises.map((c, i) => milieu(c, g.chaises[(i + 1) % n]))];
|
||||||
|
for (const point of candidats) {
|
||||||
|
assert.deepEqual(designer(point, geometries, 0.2), { table: g.id });
|
||||||
|
points += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(points, 2 * ATTENDUES[nom] + geometries.length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tables qui se recouvrent : la dernière reçue, peinte au-dessus', () => {
|
||||||
|
test('deux tables au même centre : la seconde', () => {
|
||||||
|
const geometries = [geometrieTable(table(1, 'ronde', 0, 0), 8), geometrieTable(table(2, 'ronde', 0, 0), 8)];
|
||||||
|
const chaise = geometries[0].chaises[2];
|
||||||
|
assert.deepEqual(designer(chaise, geometries, 1), { table: 2, siege: 3 });
|
||||||
|
assert.deepEqual(designer({ x: 0, y: 0 }, geometries, 0.2), { table: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('régime sièges : le plateau du dessus cache la chaise de dessous', () => {
|
||||||
|
// Rondes de 8 en (0, 0) et (100, 0) : le siège 3 de la première, en
|
||||||
|
// (98,394 ; 0), tombe dans le plateau de la seconde, de rayon 76,394.
|
||||||
|
const premiere = geometrieTable(table(1, 'ronde', 0, 0), 8);
|
||||||
|
const seconde = geometrieTable(table(2, 'ronde', 100, 0), 8);
|
||||||
|
const cachee = premiere.chaises[2];
|
||||||
|
assert.equal(designer(cachee, [premiere, seconde], 1), null);
|
||||||
|
assert.deepEqual(designer(cachee, [seconde, premiere], 1), { table: 1, siege: 3 });
|
||||||
|
assert.deepEqual(designer(premiere.chaises[6], [premiere, seconde], 1), { table: 1, siege: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('régimes différents : chaque table se juge sur son propre pas', () => {
|
||||||
|
// À k = 0,35, la ronde de 8 (pas 60) est en régime table, la carrée de 3
|
||||||
|
// (pas 70) en régime sièges.
|
||||||
|
const ronde = geometrieTable(table(1, 'ronde', 0, 0), 8);
|
||||||
|
const carree = geometrieTable(table(2, 'carree', 1000, 0), 3);
|
||||||
|
const geometries = [ronde, carree];
|
||||||
|
assert.deepEqual(designer(ronde.chaises[0], geometries, 0.35), { table: 1 });
|
||||||
|
assert.deepEqual(designer(carree.chaises[0], geometries, 0.35), { table: 2, siege: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sans table, aucun point n’est désigné et tout est le sol', () => {
|
||||||
|
assert.equal(designer({ x: 0, y: 0 }, [], 1), null);
|
||||||
|
assert.equal(surLeSol({ x: 0, y: 0 }, []), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
src/geometrie/echelle.js
Normal file
40
src/geometrie/echelle.js
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// L'échelle graphique du plan (§ 7.7) : une barre d'une longueur ronde, que le
|
||||||
|
// lecteur reporte sur le dessin. Une barre reste juste quand une impression
|
||||||
|
// change le facteur d'échelle, là où un rapport écrit en chiffres deviendrait
|
||||||
|
// faux.
|
||||||
|
|
||||||
|
// Mantisses des longueurs rondes, de la plus grande à la plus petite.
|
||||||
|
const MANTISSES = [5, 2, 1];
|
||||||
|
|
||||||
|
// m × 10ⁿ, au plus près : une puissance négative divise par 10⁻ⁿ, que
|
||||||
|
// l'arithmétique flottante représente exactement, plutôt que de multiplier
|
||||||
|
// par 10ⁿ, qu'elle arrondit.
|
||||||
|
const ronde = (m, n) => (n >= 0 ? m * 10 ** n : m / 10 ** -n);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La plus grande longueur ronde — 1, 2 ou 5 × 10ⁿ cm, n entier de signe
|
||||||
|
* quelconque — dont la barre tient dans largeurMaxPx à l'échelle k, et la
|
||||||
|
* largeur de cette barre, longueurCm × k. La recherche part de la décade
|
||||||
|
* au-dessus de largeurMaxPx / k et descend, mantisse par mantisse, jusqu'à la
|
||||||
|
* première longueur qui tient : l'arrondi du logarithme ne fait rien manquer.
|
||||||
|
* Lève RangeError quand k ou largeurMaxPx n'est pas un nombre fini > 0.
|
||||||
|
* @param {number} k px/cm
|
||||||
|
* @param {number} largeurMaxPx
|
||||||
|
* @returns {{longueurCm: number, largeurPx: number}}
|
||||||
|
*/
|
||||||
|
export function echelleGraphique(k, largeurMaxPx) {
|
||||||
|
for (const [nom, valeur] of [['k', k], ['largeur', largeurMaxPx]]) {
|
||||||
|
if (typeof valeur !== 'number' || !Number.isFinite(valeur) || valeur <= 0) {
|
||||||
|
throw new RangeError(`${nom} : nombre fini > 0 attendu, reçu ${String(valeur)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let n = Math.floor(Math.log10(largeurMaxPx / k)) + 1; ; n -= 1) {
|
||||||
|
for (const m of MANTISSES) {
|
||||||
|
const longueurCm = ronde(m, n);
|
||||||
|
if (longueurCm * k <= largeurMaxPx) return { longueurCm, largeurPx: longueurCm * k };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/geometrie/echelle.test.js
Normal file
55
src/geometrie/echelle.test.js
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Épreuves de l'échelle graphique (§ 7.7) : la plus grande longueur ronde, 1,
|
||||||
|
// 2 ou 5 × 10ⁿ cm, dont la barre tient dans la largeur donnée à l'échelle k.
|
||||||
|
// Les cas chiffrés sont écrits en clair ; le balayage de k vérifie chaque
|
||||||
|
// résultat par la définition, non par une seconde implémentation.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
|
import { echelleGraphique } from './echelle.js';
|
||||||
|
|
||||||
|
// Vrai quand longueur vaut 1, 2 ou 5 × 10ⁿ, n entier de signe quelconque,
|
||||||
|
// à l'arrondi des nombres flottants près.
|
||||||
|
function estRonde(longueur) {
|
||||||
|
const puissance = 10 ** Math.floor(Math.log10(longueur) + 1e-12);
|
||||||
|
const mantisse = longueur / puissance;
|
||||||
|
return [1, 2, 5].some((m) => Math.abs(mantisse - m) < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
// La valeur ronde qui suit longueur : 1 → 2, 2 → 5, 5 → 10.
|
||||||
|
function rondeSuivante(longueur) {
|
||||||
|
const puissance = 10 ** Math.floor(Math.log10(longueur) + 1e-12);
|
||||||
|
const mantisse = Math.round(longueur / puissance);
|
||||||
|
return (mantisse === 1 ? 2 : mantisse === 2 ? 5 : 10) * puissance;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("échelle : l'échelle graphique (§ 7.7)", () => {
|
||||||
|
test('k = 1, 0,19 et 10 dans 200 px', () => {
|
||||||
|
assert.deepEqual(echelleGraphique(1, 200), { longueurCm: 200, largeurPx: 200 });
|
||||||
|
const etroite = echelleGraphique(0.19, 200);
|
||||||
|
assert.equal(etroite.longueurCm, 1000);
|
||||||
|
assert.ok(Math.abs(etroite.largeurPx - 190) < 1e-9, `${etroite.largeurPx}`);
|
||||||
|
assert.deepEqual(echelleGraphique(10, 200), { longueurCm: 20, largeurPx: 200 });
|
||||||
|
assert.deepEqual(echelleGraphique(10, 5), { longueurCm: 0.5, largeurPx: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pour chaque k de 0,05 à 10, raison 1,07 : longueur ronde, barre dans 200 px, la ronde suivante dépasserait', () => {
|
||||||
|
let comptes = 0;
|
||||||
|
for (let k = 0.05; k <= 10; k *= 1.07) {
|
||||||
|
const { longueurCm, largeurPx } = echelleGraphique(k, 200);
|
||||||
|
assert.ok(estRonde(longueurCm), `k = ${k} : ${longueurCm}`);
|
||||||
|
assert.ok(Math.abs(largeurPx - longueurCm * k) < 1e-9, `k = ${k}`);
|
||||||
|
assert.ok(largeurPx <= 200, `k = ${k} : ${largeurPx}`);
|
||||||
|
assert.ok(rondeSuivante(longueurCm) * k > 200, `k = ${k} : ${longueurCm}`);
|
||||||
|
comptes += 1;
|
||||||
|
}
|
||||||
|
assert.equal(comptes, 79);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("k ou largeur qui n'est pas un nombre fini positif est refusé", () => {
|
||||||
|
for (const [k, largeur] of [[0, 200], [-1, 200], [Number.NaN, 200], [Number.POSITIVE_INFINITY, 200], [1, 0], [1, -5], [1, Number.NaN], [null, 200]]) {
|
||||||
|
assert.throws(() => echelleGraphique(k, largeur), RangeError, `${k}, ${largeur}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
239
src/geometrie/tables.js
Normal file
239
src/geometrie/tables.js
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// La géométrie d'une table (§ 7.1, § 7.7), en centimètres de dessin, l'axe y
|
||||||
|
// vers le bas : plateau tiré du nombre de places, chaises numérotées depuis le
|
||||||
|
// haut dans le sens horaire, emprise, poignée et origine du bloc de noms. Le
|
||||||
|
// dessin pose les chaises par geometrieTable et la désignation d'un siège
|
||||||
|
// (designation.js) lit les mêmes chaises : une seule implémentation, que le
|
||||||
|
// dessin et le dépôt partagent. Aucune table ne tourne : la géométrie ne
|
||||||
|
// porte aucun angle.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} GeometrieTable en cm
|
||||||
|
* @property {number} id
|
||||||
|
* @property {number} numero
|
||||||
|
* @property {'ronde'|'carree'} forme
|
||||||
|
* @property {{x: number, y: number}} centre
|
||||||
|
* @property {number} demiTaille rayon du plateau (ronde) ou demi-côté (carrée)
|
||||||
|
* @property {number} pas distance entre deux sièges voisins, au bord du plateau
|
||||||
|
* @property {Array<{siege: number, x: number, y: number, rayon: number}>} chaises sièges 1 à places
|
||||||
|
* @property {{x: number, y: number}} poignee centre du disque de la poignée
|
||||||
|
* @property {number} rayonEmprise demiTaille + 2 × RAYON_CHAISE : rayon du disque d'emprise
|
||||||
|
* (ronde) ou demi-côté du carré d'emprise (carrée)
|
||||||
|
* @property {{x: number, y: number}} origineListe coin haut gauche du bloc de noms
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Diamètre minimal d'une table ronde, côté minimal d'une carrée, en cm :
|
||||||
|
* réglage par défaut, non norme (§ 7.7). */
|
||||||
|
export const PLANCHER_DIAMETRE = 70;
|
||||||
|
/** Longueur de bord par convive, en cm (§ 7.7). */
|
||||||
|
export const LARGEUR_PAR_PLACE = 60;
|
||||||
|
/** Rayon d'une chaise, en cm. */
|
||||||
|
export const RAYON_CHAISE = 22;
|
||||||
|
/** Rayon du disque de la poignée, en cm. */
|
||||||
|
export const RAYON_POIGNEE = 15;
|
||||||
|
/** Distance entre l'emprise et le bord de la poignée, en cm. */
|
||||||
|
export const ECART_POIGNEE = 8;
|
||||||
|
/** Distance entre l'emprise et le bloc de noms, en cm. */
|
||||||
|
export const ECART_LISTE = 10;
|
||||||
|
/** Distance entre deux cases de la grille des tables créées, en cm. */
|
||||||
|
export const PAS_GRILLE = 300;
|
||||||
|
|
||||||
|
// Accroissement du rayon d'une ronde pour une place de plus, au-dessus du
|
||||||
|
// plancher : le périmètre croît de LARGEUR_PAR_PLACE, le rayon de 60 / 2π.
|
||||||
|
const RAYON_PAR_PLACE = LARGEUR_PAR_PLACE / (2 * Math.PI);
|
||||||
|
|
||||||
|
// Les quatre côtés d'une carrée, dans l'ordre où ils reçoivent les places —
|
||||||
|
// haut, droit, bas, gauche — : le coin où commence le côté dans le sens
|
||||||
|
// horaire, en demi-côtés depuis le centre, la direction de parcours et la
|
||||||
|
// normale sortante.
|
||||||
|
const COTES = [
|
||||||
|
{ depart: { x: -1, y: -1 }, direction: { x: 1, y: 0 }, normale: { x: 0, y: -1 } },
|
||||||
|
{ depart: { x: 1, y: -1 }, direction: { x: 0, y: 1 }, normale: { x: 1, y: 0 } },
|
||||||
|
{ depart: { x: 1, y: 1 }, direction: { x: -1, y: 0 }, normale: { x: 0, y: 1 } },
|
||||||
|
{ depart: { x: -1, y: 1 }, direction: { x: 0, y: -1 }, normale: { x: -1, y: 0 } },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Lève RangeError sur un nombre de places qui n'est pas un entier ≥ 1, ou un
|
||||||
|
// diamètre ou côté saisi qui n'est pas un nombre fini > 0 : la géométrie
|
||||||
|
// d'une table se calcule sans valeur de repli.
|
||||||
|
function exigerPlaces(places, saisi) {
|
||||||
|
if (!Number.isInteger(places) || places < 1) {
|
||||||
|
throw new RangeError(`nombre de places entier ≥ 1 attendu, reçu ${JSON.stringify(places)}`);
|
||||||
|
}
|
||||||
|
if (saisi !== null && !(Number.isFinite(saisi) && saisi > 0)) {
|
||||||
|
throw new RangeError(`dimension saisie > 0 attendue, reçu ${JSON.stringify(saisi)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diamètre d'une table ronde (§ 7.7) : max(70, places × 60 / π), ou le
|
||||||
|
* diamètre saisi, qui l'emporte.
|
||||||
|
*
|
||||||
|
* @param {number} places entier ≥ 1
|
||||||
|
* @param {{saisi?: number|null}} [options]
|
||||||
|
* @returns {number} cm
|
||||||
|
*/
|
||||||
|
export function diametre(places, { saisi = null } = {}) {
|
||||||
|
exigerPlaces(places, saisi);
|
||||||
|
return saisi ?? Math.max(PLANCHER_DIAMETRE, (places * LARGEUR_PAR_PLACE) / Math.PI);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Côté d'une table carrée : max(70, ⌈places / 4⌉ × 60) — le côté le plus
|
||||||
|
* garni porte ⌈places / 4⌉ sièges —, ou le côté saisi, qui l'emporte.
|
||||||
|
*
|
||||||
|
* @param {number} places entier ≥ 1
|
||||||
|
* @param {{saisi?: number|null}} [options]
|
||||||
|
* @returns {number} cm
|
||||||
|
*/
|
||||||
|
export function cote(places, { saisi = null } = {}) {
|
||||||
|
exigerPlaces(places, saisi);
|
||||||
|
return saisi ?? Math.max(PLANCHER_DIAMETRE, Math.ceil(places / 4) * LARGEUR_PAR_PLACE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chaises d'une ronde : sur le cercle de rayon demiTaille + RAYON_CHAISE,
|
||||||
|
// siège s à l'angle −π/2 + 2π(s − 1)/places, le siège 1 en haut et la
|
||||||
|
// numérotation dans le sens horaire à l'écran (y vers le bas).
|
||||||
|
function chaisesRonde(centre, demiTaille, places) {
|
||||||
|
const rayon = demiTaille + RAYON_CHAISE;
|
||||||
|
return Array.from({ length: places }, (_, i) => {
|
||||||
|
const angle = -Math.PI / 2 + (2 * Math.PI * i) / places;
|
||||||
|
return { siege: i + 1, x: centre.x + rayon * Math.cos(angle), y: centre.y + rayon * Math.sin(angle), rayon: RAYON_CHAISE };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nombre de sièges de chaque côté d'une carrée, dans l'ordre de COTES :
|
||||||
|
// ⌊places / 4⌋ chacun, et une place en surplus à chacun des premiers côtés.
|
||||||
|
const siegesParCote = (places) => COTES.map((_, i) => Math.floor(places / 4) + (i < places % 4 ? 1 : 0));
|
||||||
|
|
||||||
|
// Chaises d'une carrée : sur chaque côté, n sièges espacés de côté / n, le
|
||||||
|
// premier à côté / 2n du coin de départ, leur centre à RAYON_CHAISE au-delà
|
||||||
|
// du bord ; le siège 1 est le premier du côté haut.
|
||||||
|
function chaisesCarree(centre, demiTaille, places) {
|
||||||
|
const chaises = [];
|
||||||
|
siegesParCote(places).forEach((nombre, i) => {
|
||||||
|
const { depart, direction, normale } = COTES[i];
|
||||||
|
for (let j = 0; j < nombre; j += 1) {
|
||||||
|
const avance = ((j + 0.5) * 2 * demiTaille) / nombre;
|
||||||
|
chaises.push({
|
||||||
|
siege: chaises.length + 1,
|
||||||
|
x: centre.x + depart.x * demiTaille + direction.x * avance + normale.x * RAYON_CHAISE,
|
||||||
|
y: centre.y + depart.y * demiTaille + direction.y * avance + normale.y * RAYON_CHAISE,
|
||||||
|
rayon: RAYON_CHAISE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return chaises;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Géométrie d'une table, en cm (contrat de données, GeometrieTable).
|
||||||
|
*
|
||||||
|
* Le pas d'une ronde est π × diamètre / places, calculé sous la forme
|
||||||
|
* max(60, 70π / places) qui lui est égale : elle rend 60 exactement dès que
|
||||||
|
* le périmètre borne la table, là où le produit rend 59,999… pour certains
|
||||||
|
* nombres de places et ferait basculer le régime des cibles (designation.js)
|
||||||
|
* sous le seuil. Le pas d'une carrée est son côté divisé par le nombre de
|
||||||
|
* sièges du côté le plus garni.
|
||||||
|
*
|
||||||
|
* La poignée est un disque de rayon RAYON_POIGNEE sur la diagonale à +45°
|
||||||
|
* (vers le bas à droite), son bord à ECART_POIGNEE de l'emprise : son centre
|
||||||
|
* est à rayonEmprise + 23 cm du centre d'une ronde, à rayonEmprise × √2 + 23
|
||||||
|
* cm de celui d'une carrée, au-delà du coin de son emprise.
|
||||||
|
*
|
||||||
|
* @param {{id: number, numero: number, forme: 'ronde'|'carree', position: {x: number, y: number}}} table
|
||||||
|
* l'enregistrement de charge.tables ; position est le centre
|
||||||
|
* @param {number} places la capacité courante, entier ≥ 1
|
||||||
|
* @returns {GeometrieTable}
|
||||||
|
* @throws {RangeError} pour une forme inconnue ou un nombre de places invalide
|
||||||
|
*/
|
||||||
|
export function geometrieTable(table, places) {
|
||||||
|
const { id, numero, forme } = table;
|
||||||
|
if (forme !== 'ronde' && forme !== 'carree') {
|
||||||
|
throw new RangeError(`forme de table inconnue : ${JSON.stringify(forme)}`);
|
||||||
|
}
|
||||||
|
const centre = { x: table.position.x, y: table.position.y };
|
||||||
|
const ronde = forme === 'ronde';
|
||||||
|
const demiTaille = (ronde ? diametre(places) : cote(places)) / 2;
|
||||||
|
const pas = ronde
|
||||||
|
? Math.max(LARGEUR_PAR_PLACE, (PLANCHER_DIAMETRE * Math.PI) / places)
|
||||||
|
: (2 * demiTaille) / Math.ceil(places / 4);
|
||||||
|
const chaises = ronde ? chaisesRonde(centre, demiTaille, places) : chaisesCarree(centre, demiTaille, places);
|
||||||
|
const rayonEmprise = demiTaille + 2 * RAYON_CHAISE;
|
||||||
|
const distancePoignee = (ronde ? rayonEmprise : rayonEmprise * Math.SQRT2) + ECART_POIGNEE + RAYON_POIGNEE;
|
||||||
|
const decalage = distancePoignee / Math.SQRT2;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
numero,
|
||||||
|
forme,
|
||||||
|
centre,
|
||||||
|
demiTaille,
|
||||||
|
pas,
|
||||||
|
chaises,
|
||||||
|
poignee: { x: centre.x + decalage, y: centre.y + decalage },
|
||||||
|
rayonEmprise,
|
||||||
|
origineListe: { x: centre.x + rayonEmprise + ECART_LISTE, y: centre.y - demiTaille },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rectangle englobant des emprises : pour chaque table, le carré de
|
||||||
|
* demi-côté rayonEmprise centré sur elle, qui circonscrit le disque d'une
|
||||||
|
* ronde et égale l'emprise d'une carrée.
|
||||||
|
*
|
||||||
|
* @param {GeometrieTable[]} geometries
|
||||||
|
* @returns {{minX: number, minY: number, maxX: number, maxY: number}|null} null sans table
|
||||||
|
*/
|
||||||
|
export function englobant(geometries) {
|
||||||
|
if (geometries.length === 0) return null;
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
for (const { centre, rayonEmprise } of geometries) {
|
||||||
|
minX = Math.min(minX, centre.x - rayonEmprise);
|
||||||
|
minY = Math.min(minY, centre.y - rayonEmprise);
|
||||||
|
maxX = Math.max(maxX, centre.x + rayonEmprise);
|
||||||
|
maxY = Math.max(maxY, centre.y + rayonEmprise);
|
||||||
|
}
|
||||||
|
return { minX, minY, maxX, maxY };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position d'une table créée : la première case libre, ligne par ligne, d'une
|
||||||
|
* grille de PAS_GRILLE cm à ⌈√(T + 1)⌉ colonnes, T = centres.length, partant
|
||||||
|
* de (0, 0) ; une case est prise quand un centre est à PAS_GRILLE / 2 ou
|
||||||
|
* moins. centres : les positions de charge.tables, que l'appelant passe sans
|
||||||
|
* construire de géométrie.
|
||||||
|
*
|
||||||
|
* La recherche s'arrête : un centre est à PAS_GRILLE / 2 ou moins d'au plus
|
||||||
|
* deux cases, le plus petit disque qui contient trois cases ayant un rayon
|
||||||
|
* d'au moins PAS_GRILLE / √2 ; l'une des 2T + 1 premières cases est donc
|
||||||
|
* libre.
|
||||||
|
*
|
||||||
|
* @param {Array<{x: number, y: number}>} centres
|
||||||
|
* @returns {{x: number, y: number}}
|
||||||
|
*/
|
||||||
|
export function caseLibre(centres) {
|
||||||
|
const colonnes = Math.ceil(Math.sqrt(centres.length + 1));
|
||||||
|
for (let rang = 0; ; rang += 1) {
|
||||||
|
const candidate = { x: (rang % colonnes) * PAS_GRILLE, y: Math.floor(rang / colonnes) * PAS_GRILLE };
|
||||||
|
const prise = centres.some(({ x, y }) => Math.hypot(x - candidate.x, y - candidate.y) <= PAS_GRILLE / 2);
|
||||||
|
if (!prise) return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nombre de places sous la poignée : placesInitiales + round(ecartRadial ×
|
||||||
|
* 2π / 60), jamais sous 2. L'écart se compte depuis le début du geste
|
||||||
|
* (§ 7.2) ; un demi-pas exact s'arrondit vers le haut (Math.round).
|
||||||
|
*
|
||||||
|
* @param {number} placesInitiales les places au début du geste
|
||||||
|
* @param {number} ecartRadial cm, projeté sur l'axe de la poignée, positif en s'éloignant du centre
|
||||||
|
* @returns {number} entier ≥ 2
|
||||||
|
*/
|
||||||
|
export function placesSousPoignee(placesInitiales, ecartRadial) {
|
||||||
|
return Math.max(2, placesInitiales + Math.round(ecartRadial / RAYON_PAR_PLACE));
|
||||||
|
}
|
||||||
331
src/geometrie/tables.test.js
Normal file
331
src/geometrie/tables.test.js
Normal file
|
|
@ -0,0 +1,331 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Épreuves de la géométrie des tables (§ 7.1, § 7.7) : diamètre et côté tirés
|
||||||
|
// du nombre de places, chaises posées dans le sens horaire depuis le haut,
|
||||||
|
// emprise, poignée hors de toute chaise, bloc de noms, englobant, case libre
|
||||||
|
// d'une table créée et nombre de places sous la poignée. Les valeurs
|
||||||
|
// attendues se calculent à la main dans le commentaire qui les précède.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
|
import { surLeSol } from './designation.js';
|
||||||
|
import {
|
||||||
|
ECART_LISTE,
|
||||||
|
ECART_POIGNEE,
|
||||||
|
LARGEUR_PAR_PLACE,
|
||||||
|
PAS_GRILLE,
|
||||||
|
PLANCHER_DIAMETRE,
|
||||||
|
RAYON_CHAISE,
|
||||||
|
RAYON_POIGNEE,
|
||||||
|
caseLibre,
|
||||||
|
cote,
|
||||||
|
diametre,
|
||||||
|
englobant,
|
||||||
|
geometrieTable,
|
||||||
|
placesSousPoignee,
|
||||||
|
} from './tables.js';
|
||||||
|
|
||||||
|
const EPSILON = 1e-9;
|
||||||
|
|
||||||
|
// Affirme |reel − attendu| ≤ EPSILON, le libellé nommant la grandeur.
|
||||||
|
function proche(reel, attendu, libelle) {
|
||||||
|
assert.ok(Math.abs(reel - attendu) <= EPSILON, `${libelle} : ${reel} au lieu de ${attendu}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function procheDuPoint(reel, attendu, libelle) {
|
||||||
|
proche(reel.x, attendu.x, `${libelle}, x`);
|
||||||
|
proche(reel.y, attendu.y, `${libelle}, y`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
|
||||||
|
// Une table du fichier d'état : identifiant, numéro, forme et centre.
|
||||||
|
const table = (id, forme, x, y) => ({ id, numero: id, sieges: null, forme, position: { x, y } });
|
||||||
|
|
||||||
|
describe('constantes', () => {
|
||||||
|
test('les valeurs du contrat', () => {
|
||||||
|
assert.equal(PLANCHER_DIAMETRE, 70);
|
||||||
|
assert.equal(LARGEUR_PAR_PLACE, 60);
|
||||||
|
assert.equal(RAYON_CHAISE, 22);
|
||||||
|
assert.equal(RAYON_POIGNEE, 15);
|
||||||
|
assert.equal(ECART_POIGNEE, 8);
|
||||||
|
assert.equal(ECART_LISTE, 10);
|
||||||
|
assert.equal(PAS_GRILLE, 300);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('diametre (§ 7.7)', () => {
|
||||||
|
test('le plancher de 70 cm borne 2 et 3 places, le périmètre borne dès 4', () => {
|
||||||
|
assert.equal(diametre(2), 70);
|
||||||
|
assert.equal(diametre(3), 70);
|
||||||
|
// 4 × 60 / π = 76,394 > 70 : un plancher appliqué jusqu'à 6 places rendrait 70.
|
||||||
|
proche(diametre(4), 240 / Math.PI, 'diamètre de 4');
|
||||||
|
proche(diametre(5), 300 / Math.PI, 'diamètre de 5');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('115, 153 et 191 cm pour 6, 8 et 10 places, arrondis (§ 7.7)', () => {
|
||||||
|
assert.equal(Math.round(diametre(6)), 115);
|
||||||
|
assert.equal(Math.round(diametre(8)), 153);
|
||||||
|
assert.equal(Math.round(diametre(10)), 191);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un diamètre saisi l’emporte', () => {
|
||||||
|
assert.equal(diametre(8, { saisi: 180 }), 180);
|
||||||
|
assert.equal(diametre(2, { saisi: 50 }), 50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cote', () => {
|
||||||
|
test('max(70, ⌈places / 4⌉ × 60)', () => {
|
||||||
|
assert.equal(cote(3), 70);
|
||||||
|
assert.equal(cote(5), 120);
|
||||||
|
assert.equal(cote(8), 120);
|
||||||
|
assert.equal(cote(9), 180);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un côté saisi l’emporte', () => {
|
||||||
|
assert.equal(cote(8, { saisi: 150 }), 150);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('geometrieTable, ronde', () => {
|
||||||
|
// Ronde de 8 au centre (300, 600) : diamètre 480/π, demi-taille 240/π
|
||||||
|
// (≈ 76,394), chaises sur le cercle de rayon 240/π + 22 (≈ 98,394).
|
||||||
|
const g = geometrieTable(table(4, 'ronde', 300, 600), 8);
|
||||||
|
const r = 240 / Math.PI;
|
||||||
|
|
||||||
|
test('identité, forme, centre, demi-taille et pas', () => {
|
||||||
|
assert.equal(g.id, 4);
|
||||||
|
assert.equal(g.numero, 4);
|
||||||
|
assert.equal(g.forme, 'ronde');
|
||||||
|
assert.deepEqual(g.centre, { x: 300, y: 600 });
|
||||||
|
proche(g.demiTaille, r, 'demi-taille');
|
||||||
|
proche(g.pas, 60, 'pas');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('huit chaises de rayon 22, siège 1 en haut, sens horaire', () => {
|
||||||
|
assert.equal(g.chaises.length, 8);
|
||||||
|
g.chaises.forEach((chaise, i) => {
|
||||||
|
assert.equal(chaise.siege, i + 1);
|
||||||
|
assert.equal(chaise.rayon, RAYON_CHAISE);
|
||||||
|
proche(distance(chaise, g.centre), r + 22, `distance du siège ${chaise.siege}`);
|
||||||
|
});
|
||||||
|
procheDuPoint(g.chaises[0], { x: 300, y: 600 - (r + 22) }, 'siège 1');
|
||||||
|
procheDuPoint(g.chaises[2], { x: 300 + r + 22, y: 600 }, 'siège 3');
|
||||||
|
procheDuPoint(g.chaises[4], { x: 300, y: 600 + r + 22 }, 'siège 5');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emprise, poignée et origine de la liste', () => {
|
||||||
|
proche(g.rayonEmprise, r + 44, 'rayon d’emprise');
|
||||||
|
const ecart = (r + 44 + 23) / Math.SQRT2;
|
||||||
|
procheDuPoint(g.poignee, { x: 300 + ecart, y: 600 + ecart }, 'poignée');
|
||||||
|
procheDuPoint(g.origineListe, { x: 300 + r + 54, y: 600 - r }, 'origine de la liste');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ronde de 3 : plancher de 70 cm, pas 70π/3', () => {
|
||||||
|
const petite = geometrieTable(table(1, 'ronde', 0, 0), 3);
|
||||||
|
proche(petite.demiTaille, 35, 'demi-taille');
|
||||||
|
proche(petite.pas, (70 * Math.PI) / 3, 'pas');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le pas vaut 60 exactement au-dessus du plancher, de 4 à 40 places', () => {
|
||||||
|
// Le produit π × (places × 60 / π) / places rend 59,999… pour 7 places :
|
||||||
|
// le régime des cibles basculerait alors un cran trop tôt.
|
||||||
|
let comptees = 0;
|
||||||
|
for (let places = 4; places <= 40; places += 1) {
|
||||||
|
assert.equal(geometrieTable(table(1, 'ronde', 0, 0), places).pas, 60, `${places} places`);
|
||||||
|
comptees += 1;
|
||||||
|
}
|
||||||
|
assert.equal(comptees, 37);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('geometrieTable, carrée', () => {
|
||||||
|
// Carrée de 8 au centre (300, 600) : côté 120, demi-côté 60, deux sièges
|
||||||
|
// par côté à 30 et 90 cm du coin, centres à 60 + 22 = 82 cm de l'axe.
|
||||||
|
const g = geometrieTable(table(2, 'carree', 300, 600), 8);
|
||||||
|
|
||||||
|
test('côté 120, pas 60, deux sièges par côté dans le sens horaire', () => {
|
||||||
|
assert.equal(g.forme, 'carree');
|
||||||
|
assert.equal(g.demiTaille, 60);
|
||||||
|
assert.equal(g.pas, 60);
|
||||||
|
assert.equal(g.chaises.length, 8);
|
||||||
|
const attendus = [
|
||||||
|
[270, 518], [330, 518], // haut, de gauche à droite
|
||||||
|
[382, 570], [382, 630], // droit, de haut en bas
|
||||||
|
[330, 682], [270, 682], // bas, de droite à gauche
|
||||||
|
[218, 630], [218, 570], // gauche, de bas en haut
|
||||||
|
];
|
||||||
|
g.chaises.forEach((chaise, i) => {
|
||||||
|
assert.equal(chaise.siege, i + 1);
|
||||||
|
assert.equal(chaise.rayon, RAYON_CHAISE);
|
||||||
|
procheDuPoint(chaise, { x: attendus[i][0], y: attendus[i][1] }, `siège ${i + 1}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emprise 104, poignée au-delà du coin, liste en (414, 540)', () => {
|
||||||
|
assert.equal(g.rayonEmprise, 104);
|
||||||
|
procheDuPoint(g.poignee, { x: 404 + 23 / Math.SQRT2, y: 704 + 23 / Math.SQRT2 }, 'poignée');
|
||||||
|
procheDuPoint(g.origineListe, { x: 414, y: 540 }, 'origine de la liste');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('carrée de 3 : côtés garnis 1, 1, 1, 0 et pas 70', () => {
|
||||||
|
// Côté 70, demi-côté 35 : un siège au milieu du haut, du droit et du bas.
|
||||||
|
const petite = geometrieTable(table(1, 'carree', 0, 0), 3);
|
||||||
|
assert.equal(petite.pas, 70);
|
||||||
|
assert.equal(petite.chaises.length, 3);
|
||||||
|
procheDuPoint(petite.chaises[0], { x: 0, y: -57 }, 'siège 1');
|
||||||
|
procheDuPoint(petite.chaises[1], { x: 57, y: 0 }, 'siège 2');
|
||||||
|
procheDuPoint(petite.chaises[2], { x: 0, y: 57 }, 'siège 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('carrée de 5 : le côté haut reçoit la place en surplus', () => {
|
||||||
|
// Côté 120 : 2 sièges en haut, puis 1 à droite, en bas et à gauche.
|
||||||
|
const g5 = geometrieTable(table(1, 'carree', 0, 0), 5);
|
||||||
|
assert.equal(g5.pas, 60);
|
||||||
|
const attendus = [[-30, -82], [30, -82], [82, 0], [0, 82], [-82, 0]];
|
||||||
|
assert.equal(g5.chaises.length, attendus.length);
|
||||||
|
g5.chaises.forEach((chaise, i) => procheDuPoint(chaise, { x: attendus[i][0], y: attendus[i][1] }, `siège ${i + 1}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('emprise et chaises, de 2 à 40 places', () => {
|
||||||
|
test('l’emprise contient chaque chaise, et deux chaises ne se touchent pas', () => {
|
||||||
|
let tables = 0;
|
||||||
|
for (const forme of ['ronde', 'carree']) {
|
||||||
|
for (let places = 2; places <= 40; places += 1) {
|
||||||
|
const g = geometrieTable(table(1, forme, 10, -20), places);
|
||||||
|
assert.equal(g.chaises.length, places);
|
||||||
|
proche(g.rayonEmprise, g.demiTaille + 2 * RAYON_CHAISE, `${forme} de ${places}, emprise`);
|
||||||
|
for (const chaise of g.chaises) {
|
||||||
|
const dx = Math.abs(chaise.x - g.centre.x);
|
||||||
|
const dy = Math.abs(chaise.y - g.centre.y);
|
||||||
|
const portee = forme === 'ronde' ? Math.hypot(dx, dy) : Math.max(dx, dy);
|
||||||
|
assert.ok(portee + chaise.rayon <= g.rayonEmprise + EPSILON, `${forme} de ${places}, siège ${chaise.siege}`);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < places; i += 1) {
|
||||||
|
for (let j = i + 1; j < places; j += 1) {
|
||||||
|
assert.ok(distance(g.chaises[i], g.chaises[j]) >= 2 * RAYON_CHAISE, `${forme} de ${places}, sièges ${i + 1} et ${j + 1}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tables += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(tables, 78);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('la poignée ne touche aucune chaise (§ 7.1)', () => {
|
||||||
|
test('rondes de 3, 8 et 16, carrées de 3, 8, 12 et 16', () => {
|
||||||
|
const cas = [
|
||||||
|
['ronde', 3], ['ronde', 8], ['ronde', 16],
|
||||||
|
['carree', 3], ['carree', 8], ['carree', 12], ['carree', 16],
|
||||||
|
];
|
||||||
|
let eprouvees = 0;
|
||||||
|
for (const [forme, places] of cas) {
|
||||||
|
const g = geometrieTable(table(1, forme, 0, 0), places);
|
||||||
|
assert.ok(g.chaises.length > 0);
|
||||||
|
for (const chaise of g.chaises) {
|
||||||
|
assert.ok(
|
||||||
|
distance(g.poignee, chaise) > RAYON_CHAISE + RAYON_POIGNEE,
|
||||||
|
`${forme} de ${places}, siège ${chaise.siege} à ${distance(g.poignee, chaise)} cm`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(surLeSol(g.poignee, [g]), true, `${forme} de ${places} : centre de la poignée hors du sol`);
|
||||||
|
eprouvees += 1;
|
||||||
|
}
|
||||||
|
assert.equal(eprouvees, 7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('aucune rotation (§ 7.1)', () => {
|
||||||
|
test('la géométrie ne porte aucun angle et une seule poignée', () => {
|
||||||
|
for (const forme of ['ronde', 'carree']) {
|
||||||
|
const g = geometrieTable(table(1, forme, 0, 0), 8);
|
||||||
|
assert.deepEqual(
|
||||||
|
Object.keys(g).sort(),
|
||||||
|
['centre', 'chaises', 'demiTaille', 'forme', 'id', 'numero', 'origineListe', 'pas', 'poignee', 'rayonEmprise'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(Object.keys(g.poignee).sort(), ['x', 'y']);
|
||||||
|
for (const chaise of g.chaises) assert.deepEqual(Object.keys(chaise).sort(), ['rayon', 'siege', 'x', 'y']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('geometrieTable refuse une table qu’elle ne sait pas dessiner', () => {
|
||||||
|
test('forme inconnue, places nulles ou non entières', () => {
|
||||||
|
assert.throws(() => geometrieTable(table(1, 'ovale', 0, 0), 8), RangeError);
|
||||||
|
assert.throws(() => geometrieTable(table(1, 'ronde', 0, 0), 0), RangeError);
|
||||||
|
assert.throws(() => geometrieTable(table(1, 'carree', 0, 0), 2.5), RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('diametre et cote : places invalides, dimension saisie nulle ou non finie', () => {
|
||||||
|
assert.throws(() => diametre(0), RangeError);
|
||||||
|
assert.throws(() => cote(1.5), RangeError);
|
||||||
|
assert.throws(() => diametre(8, { saisi: 0 }), RangeError);
|
||||||
|
assert.throws(() => cote(8, { saisi: Number.NaN }), RangeError);
|
||||||
|
assert.throws(() => diametre(8, { saisi: Infinity }), RangeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('englobant', () => {
|
||||||
|
test('null sans table', () => {
|
||||||
|
assert.equal(englobant([]), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deux tables : min et max exacts de leurs emprises', () => {
|
||||||
|
// Ronde de 8 en (300, 600), emprise 240/π + 44 ; carrée de 8 en
|
||||||
|
// (−100, 50), emprise 104.
|
||||||
|
const r = 240 / Math.PI + 44;
|
||||||
|
const e = englobant([
|
||||||
|
geometrieTable(table(1, 'ronde', 300, 600), 8),
|
||||||
|
geometrieTable(table(2, 'carree', -100, 50), 8),
|
||||||
|
]);
|
||||||
|
assert.deepEqual(Object.keys(e).sort(), ['maxX', 'maxY', 'minX', 'minY']);
|
||||||
|
proche(e.minX, -204, 'minX');
|
||||||
|
proche(e.minY, -54, 'minY');
|
||||||
|
proche(e.maxX, 300 + r, 'maxX');
|
||||||
|
proche(e.maxY, 600 + r, 'maxY');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('caseLibre', () => {
|
||||||
|
test('sans table, l’origine', () => {
|
||||||
|
assert.deepEqual(caseLibre([]), { x: 0, y: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deux centres en (0, 0) et (300, 0) : deux colonnes, la case suivante est (0, 300)', () => {
|
||||||
|
assert.deepEqual(caseLibre([{ x: 0, y: 0 }, { x: 300, y: 0 }]), { x: 0, y: 300 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un centre en (150, 0) prend les deux premières cases', () => {
|
||||||
|
assert.deepEqual(caseLibre([{ x: 150, y: 0 }]), { x: 0, y: 300 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un centre à plus de PAS_GRILLE / 2 laisse la case libre', () => {
|
||||||
|
assert.deepEqual(caseLibre([{ x: 150.001, y: 0 }]), { x: 0, y: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('quatre centres sur 2 × 2 : trois colonnes, la case suivante est (600, 0)', () => {
|
||||||
|
const centres = [{ x: 0, y: 0 }, { x: 300, y: 0 }, { x: 0, y: 300 }, { x: 300, y: 300 }];
|
||||||
|
assert.deepEqual(caseLibre(centres.slice(0, 3)), { x: 300, y: 300 });
|
||||||
|
assert.deepEqual(caseLibre(centres), { x: 600, y: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('placesSousPoignee', () => {
|
||||||
|
const PAS_RAYON = 60 / (2 * Math.PI);
|
||||||
|
|
||||||
|
test('écart nul, deux pas, dix pas en arrière, un pas', () => {
|
||||||
|
assert.equal(placesSousPoignee(8, 0), 8);
|
||||||
|
assert.equal(placesSousPoignee(8, 2 * PAS_RAYON), 10);
|
||||||
|
assert.equal(placesSousPoignee(8, -10 * PAS_RAYON), 2);
|
||||||
|
assert.equal(placesSousPoignee(3, PAS_RAYON), 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('l’écart s’arrondit au pas le plus proche', () => {
|
||||||
|
assert.equal(placesSousPoignee(8, 0.4 * PAS_RAYON), 8);
|
||||||
|
assert.equal(placesSousPoignee(8, 0.6 * PAS_RAYON), 9);
|
||||||
|
assert.equal(placesSousPoignee(8, -0.6 * PAS_RAYON), 7);
|
||||||
|
});
|
||||||
|
});
|
||||||
75
src/geometrie/texte.js
Normal file
75
src/geometrie/texte.js
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Le texte du plan (§ 7.4) : la troncature d'un nom dans la largeur d'un bloc
|
||||||
|
// de noms, et les ordonnées des lignes d'une liste, chaque ligne étant un
|
||||||
|
// <tspan> à décalage explicite. Aucune fonction ne lit le DOM : la largeur
|
||||||
|
// d'une chaîne vient d'une mesure injectée, getComputedTextLength au
|
||||||
|
// navigateur, sur la police rendue (§ 14.1).
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gabarit dont se mesure la hauteur de ligne d'une liste : capitales
|
||||||
|
* accentuées et jambages, jamais une chaîne moyenne (§ 7.4).
|
||||||
|
*/
|
||||||
|
export const GABARIT_HAUTEUR_LIGNE = 'Ôjgq Æ W';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corps du texte des listes de noms, en cm dans le dessin : les étiquettes ne
|
||||||
|
* sont pas à l'échelle du mobilier (§ 7.7), et ce corps fixe leur taille dans
|
||||||
|
* le dessin, que le zoom agrandit avec le reste.
|
||||||
|
*/
|
||||||
|
export const TAILLE_TEXTE_LISTE_CM = 18;
|
||||||
|
|
||||||
|
// Vrai pour la première, ou la seconde, moitié d'une paire de substitution.
|
||||||
|
const estPremiereMoitie = (code) => code >= 0xd800 && code <= 0xdbff;
|
||||||
|
const estSecondeMoitie = (code) => code >= 0xdc00 && code <= 0xdfff;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le texte entier s'il tient dans largeurMax ; sinon le plus long préfixe
|
||||||
|
* qui, suivi de la marque, y tient ; '' quand la marque seule n'y tient pas ;
|
||||||
|
* '' reste ''. Le résultat ne dépasse jamais largeurMax.
|
||||||
|
*
|
||||||
|
* La recherche avance par dichotomie sur le nombre d'unités de code du
|
||||||
|
* préfixe, la largeur d'un préfixe croissant avec sa longueur. Une coupe qui
|
||||||
|
* tomberait entre les deux moitiés d'une paire de substitution recule d'une
|
||||||
|
* unité : le préfixe ne finit jamais sur une moitié de lettre.
|
||||||
|
*
|
||||||
|
* @param {string} texte
|
||||||
|
* @param {number} largeurMax
|
||||||
|
* @param {function(string): number} mesurer largeur d'une chaîne, dans l'unité de largeurMax
|
||||||
|
* @param {string} marque ce qui suit un préfixe tronqué
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function tronquer(texte, largeurMax, mesurer, marque) {
|
||||||
|
if (texte === '' || mesurer(texte) <= largeurMax) return texte;
|
||||||
|
if (!(mesurer(marque) <= largeurMax)) return '';
|
||||||
|
// Fin d'un préfixe de n unités, ramenée hors d'une paire de substitution.
|
||||||
|
const coupe = (n) =>
|
||||||
|
n > 0 && estPremiereMoitie(texte.charCodeAt(n - 1)) && estSecondeMoitie(texte.charCodeAt(n)) ? n - 1 : n;
|
||||||
|
const avecMarque = (n) => texte.slice(0, coupe(n)) + marque;
|
||||||
|
// Invariant : le préfixe de bas unités tient, suivi de la marque ; celui de
|
||||||
|
// haut unités ne tient pas, le texte entier, suivi de la marque, compris.
|
||||||
|
let bas = 0;
|
||||||
|
let haut = texte.length;
|
||||||
|
while (haut - bas > 1) {
|
||||||
|
const milieu = bas + Math.floor((haut - bas) / 2);
|
||||||
|
if (mesurer(avecMarque(milieu)) <= largeurMax) bas = milieu;
|
||||||
|
else haut = milieu;
|
||||||
|
}
|
||||||
|
return avecMarque(bas);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordonnées des n lignes d'une liste, la première à 0, chacune d'une hauteur
|
||||||
|
* de ligne sous la précédente. Lève RangeError sur un n qui n'est pas un
|
||||||
|
* entier ≥ 0.
|
||||||
|
* @param {number} n
|
||||||
|
* @param {number} hauteurLigne
|
||||||
|
* @returns {number[]}
|
||||||
|
*/
|
||||||
|
export function decalagesLignes(n, hauteurLigne) {
|
||||||
|
if (!Number.isInteger(n) || n < 0) {
|
||||||
|
throw new RangeError(`nombre de lignes : entier ≥ 0 attendu, reçu ${String(n)}`);
|
||||||
|
}
|
||||||
|
return Array.from({ length: n }, (_, i) => i * hauteurLigne);
|
||||||
|
}
|
||||||
92
src/geometrie/texte.test.js
Normal file
92
src/geometrie/texte.test.js
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Épreuves du texte du plan (§ 7.4, § 14.1) : la troncature avec marque et
|
||||||
|
// les décalages des lignes d'une liste. La mesure est injectée : ici, une
|
||||||
|
// somme de largeurs par glyphe écrites dans l'épreuve, où le glyphe large
|
||||||
|
// coûte plus que la moyenne ; au navigateur, getComputedTextLength.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
|
import { GABARIT_HAUTEUR_LIGNE, TAILLE_TEXTE_LISTE_CM, decalagesLignes, tronquer } from './texte.js';
|
||||||
|
|
||||||
|
const MARQUE = '…';
|
||||||
|
|
||||||
|
// Largeur d'un glyphe : W 10, Æ 11, i 3, la marque 6, tout autre 6. Une
|
||||||
|
// moitié de paire de substitution isolée ne dessine rien de mesurable et
|
||||||
|
// compte 0 : une coupe au milieu d'une paire paraîtrait tenir.
|
||||||
|
const LARGEURS = new Map([['W', 10], ['Æ', 11], ['i', 3], [MARQUE, 6]]);
|
||||||
|
const MOITIE = /^[\u{D800}-\u{DFFF}]$/u;
|
||||||
|
const mesurer = (chaine) =>
|
||||||
|
[...chaine].reduce((somme, glyphe) => somme + (MOITIE.test(glyphe) ? 0 : (LARGEURS.get(glyphe) ?? 6)), 0);
|
||||||
|
|
||||||
|
// Une moitié de paire de substitution isolée, n'importe où dans la chaîne.
|
||||||
|
const MOITIE_ISOLEE = /[\u{D800}-\u{DFFF}]/u;
|
||||||
|
|
||||||
|
describe('texte : la troncature (§ 7.4, § 14.1)', () => {
|
||||||
|
test('un texte qui tient reste entier ; sinon le plus long préfixe qui, suivi de la marque, tient', () => {
|
||||||
|
assert.equal(mesurer('Ombrelle'), 48);
|
||||||
|
assert.equal(tronquer('Ombrelle', 60, mesurer, MARQUE), 'Ombrelle');
|
||||||
|
assert.equal(tronquer('Ombrelle', 48, mesurer, MARQUE), 'Ombrelle');
|
||||||
|
assert.equal(tronquer('WWWWWWWW', 60, mesurer, MARQUE), 'WWWWW…');
|
||||||
|
assert.equal(mesurer('WWWWW…'), 56);
|
||||||
|
assert.equal(tronquer('ÆÆÆÆÆÆ', 40, mesurer, MARQUE), 'ÆÆÆ…');
|
||||||
|
assert.equal(tronquer('Ombrelle', 47, mesurer, MARQUE), 'Ombrel…');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("la marque seule qui ne tient pas rend '' ; un texte entier qui tient sans marque reste ; '' reste ''", () => {
|
||||||
|
assert.equal(tronquer('Ombrelle', 4, mesurer, MARQUE), '');
|
||||||
|
assert.equal(tronquer('i', 4, mesurer, MARQUE), 'i');
|
||||||
|
assert.equal(tronquer('', 0, mesurer, MARQUE), '');
|
||||||
|
assert.equal(tronquer('', 100, mesurer, MARQUE), '');
|
||||||
|
assert.equal(tronquer('Ombrelle', 6, mesurer, MARQUE), '…');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une lettre hors du plan de base n'est jamais coupée entre ses deux unités UTF-16", () => {
|
||||||
|
const texte = '\u{1D49C}\u{1D49C}\u{1D49C}\u{1D49C}';
|
||||||
|
assert.equal(texte.length, 8);
|
||||||
|
assert.equal(tronquer(texte, 20, mesurer, MARQUE), '\u{1D49C}\u{1D49C}…');
|
||||||
|
let comptes = 0;
|
||||||
|
for (let largeur = 0; largeur <= 30; largeur += 1) {
|
||||||
|
const rendu = tronquer(texte, largeur, mesurer, MARQUE);
|
||||||
|
assert.ok(!MOITIE_ISOLEE.test(rendu), `largeur ${largeur} : ${JSON.stringify(rendu)}`);
|
||||||
|
comptes += 1;
|
||||||
|
}
|
||||||
|
assert.equal(comptes, 31);
|
||||||
|
assert.equal(tronquer('Iris \u{1D49C}mbrelle', 44, mesurer, MARQUE), 'Iris \u{1D49C}…');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le résultat ne dépasse jamais la largeur, et garde le plus long préfixe admis', () => {
|
||||||
|
const textes = ['Théo Pervenche', 'WWWWWWWWWWWW', 'ÆÆÆÆ iii WWW', 'Ondine Lacasse \u{1D49C}\u{1D49C} Grisaille', 'Ôjgq', 'x'.repeat(200)];
|
||||||
|
let comptes = 0;
|
||||||
|
for (const texte of textes) {
|
||||||
|
for (let largeur = 0; largeur <= 130; largeur += 1) {
|
||||||
|
const rendu = tronquer(texte, largeur, mesurer, MARQUE);
|
||||||
|
assert.ok(mesurer(rendu) <= largeur, `${texte} dans ${largeur} : ${rendu}`);
|
||||||
|
if (rendu !== texte && rendu !== '') {
|
||||||
|
// Le préfixe d'un glyphe de plus, suivi de la marque, ne tiendrait pas.
|
||||||
|
const prefixe = [...rendu.slice(0, -MARQUE.length)];
|
||||||
|
const suivant = [...texte][prefixe.length];
|
||||||
|
assert.ok(texte.startsWith(prefixe.join('')), `${texte} dans ${largeur}`);
|
||||||
|
assert.ok(mesurer(prefixe.join('') + suivant + MARQUE) > largeur, `${texte} dans ${largeur} : ${rendu}`);
|
||||||
|
}
|
||||||
|
comptes += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(comptes, 6 * 131);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("texte : les lignes d'une liste (§ 7.4)", () => {
|
||||||
|
test('les ordonnées des n lignes partent de 0 et avancent de la hauteur de ligne', () => {
|
||||||
|
assert.deepEqual(decalagesLignes(3, 21.5), [0, 21.5, 43]);
|
||||||
|
assert.deepEqual(decalagesLignes(1, 21.5), [0]);
|
||||||
|
assert.deepEqual(decalagesLignes(0, 21.5), []);
|
||||||
|
assert.throws(() => decalagesLignes(-1, 21.5), RangeError);
|
||||||
|
assert.throws(() => decalagesLignes(1.5, 21.5), RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le gabarit de hauteur porte capitales accentuées et jambages ; le corps des listes vaut 18 cm', () => {
|
||||||
|
assert.equal(GABARIT_HAUTEUR_LIGNE, 'Ôjgq Æ W');
|
||||||
|
assert.equal(TAILLE_TEXTE_LISTE_CM, 18);
|
||||||
|
});
|
||||||
|
});
|
||||||
218
src/geometrie/vue.js
Normal file
218
src/geometrie/vue.js
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// La vue du plan (§ 7.2) : trois nombres, k en px/cm, tx et ty en px CSS, que
|
||||||
|
// le dessin écrit dans un seul attribut, transform="translate(tx,ty) scale(k)",
|
||||||
|
// sur le groupe [data-vue] d'un <svg data-plan> sans viewBox. Toute
|
||||||
|
// conversion entre l'écran et le dessin passe par ce module : le dessin, la
|
||||||
|
// désignation d'un siège et le dépôt d'un glissement lisent la même formule.
|
||||||
|
// Aucune fonction ne lit le DOM : le cadre, rectangle du <svg> à l'écran, est
|
||||||
|
// un paramètre que l'appelant relit au pointerdown.
|
||||||
|
|
||||||
|
/** @typedef {{k: number, tx: number, ty: number}} Vue */
|
||||||
|
/** @typedef {{gauche: number, haut: number, largeur: number, hauteur: number}} Cadre */
|
||||||
|
/** @typedef {{x: number, y: number}} Point */
|
||||||
|
/** @typedef {{minX: number, minY: number, maxX: number, maxY: number}} Englobant */
|
||||||
|
|
||||||
|
/** Borne haute du zoom, en px/cm (§ 7.2). */
|
||||||
|
export const K_MAX = 10;
|
||||||
|
/** k_min = FACTEUR_K_MIN × k_ajusté ; aucune borne basse absolue (§ 7.2). */
|
||||||
|
export const FACTEUR_K_MIN = 0.5;
|
||||||
|
/** k ajusté d'un plan sans table, en px/cm : sans englobant, rien ne fixe d'échelle. */
|
||||||
|
export const K_PLAN_VIDE = 1;
|
||||||
|
/** Marge laissée de chaque côté du cadre par l'ajustement, en px. */
|
||||||
|
export const MARGE_AJUSTEMENT_PX = 16;
|
||||||
|
/** Facteur de zoom d'un cran de molette. */
|
||||||
|
export const FACTEUR_CRAN = 1.1;
|
||||||
|
|
||||||
|
// Longueur d'un cran de molette, par valeur de WheelEvent.deltaMode : en
|
||||||
|
// pixels, en lignes, en pages.
|
||||||
|
const CRAN_PAR_MODE = [100, 3, 1];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point de dessin, en cm, sous le point écran (xEcran, yEcran), en px CSS de
|
||||||
|
* la fenêtre : la formule du § 7.2, cadre soustrait avant la translation, la
|
||||||
|
* division par k en dernier.
|
||||||
|
* @param {Vue} vue @param {Cadre} cadre
|
||||||
|
* @returns {Point}
|
||||||
|
*/
|
||||||
|
export function dessinDepuisEcran(vue, cadre, xEcran, yEcran) {
|
||||||
|
return { x: (xEcran - cadre.gauche - vue.tx) / vue.k, y: (yEcran - cadre.haut - vue.ty) / vue.k };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point écran, en px CSS de la fenêtre, du point de dessin (x, y) en cm : la
|
||||||
|
* réciproque de dessinDepuisEcran.
|
||||||
|
* @param {Vue} vue @param {Cadre} cadre
|
||||||
|
* @returns {Point}
|
||||||
|
*/
|
||||||
|
export function ecranDepuisDessin(vue, cadre, x, y) {
|
||||||
|
return { x: cadre.gauche + vue.tx + vue.k * x, y: cadre.haut + vue.ty + vue.k * y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le k qui fait tenir l'englobant dans le cadre moins ses marges, plafonné à
|
||||||
|
* K_MAX ; K_PLAN_VIDE sans englobant. Null quand la largeur ou la hauteur du
|
||||||
|
* cadre ne dépasse pas 2 × MARGE_AJUSTEMENT_PX, plan vide compris : aucun k
|
||||||
|
* positif n'y fait rien tenir, et un cadre de taille nulle est celui d'un plan
|
||||||
|
* monté caché. Un englobant plat dans une direction n'y contraint rien, et un
|
||||||
|
* englobant réduit à un point prend K_MAX. Tout k rendu est fini et > 0.
|
||||||
|
* @param {Englobant|null} englobant @param {Cadre} cadre
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
export function kAjuste(englobant, cadre) {
|
||||||
|
const utileX = cadre.largeur - 2 * MARGE_AJUSTEMENT_PX;
|
||||||
|
const utileY = cadre.hauteur - 2 * MARGE_AJUSTEMENT_PX;
|
||||||
|
if (!(utileX > 0 && utileY > 0)) return null;
|
||||||
|
if (englobant === null) return K_PLAN_VIDE;
|
||||||
|
const etendueX = englobant.maxX - englobant.minX;
|
||||||
|
const etendueY = englobant.maxY - englobant.minY;
|
||||||
|
// Une étendue nulle donne Infinity, que le plafond ramène à K_MAX.
|
||||||
|
const kX = etendueX > 0 ? utileX / etendueX : Number.POSITIVE_INFINITY;
|
||||||
|
const kY = etendueY > 0 ? utileY / etendueY : Number.POSITIVE_INFINITY;
|
||||||
|
return Math.min(K_MAX, kX, kY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La vue ajustée : k = kAjuste, centre de l'englobant au centre du cadre ; un
|
||||||
|
* plan vide met l'origine du dessin au centre. Null quand kAjuste l'est :
|
||||||
|
* l'appelant garde la vue en place.
|
||||||
|
* @param {Englobant|null} englobant @param {Cadre} cadre
|
||||||
|
* @returns {Vue|null}
|
||||||
|
*/
|
||||||
|
export function ajuster(englobant, cadre) {
|
||||||
|
const k = kAjuste(englobant, cadre);
|
||||||
|
if (k === null) return null;
|
||||||
|
const cx = englobant === null ? 0 : (englobant.minX + englobant.maxX) / 2;
|
||||||
|
const cy = englobant === null ? 0 : (englobant.minY + englobant.maxY) / 2;
|
||||||
|
return { k, tx: cadre.largeur / 2 - k * cx, ty: cadre.hauteur / 2 - k * cy };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bornes du zoom : kMin = FACTEUR_K_MIN × kAjuste, kMax = K_MAX. Lève
|
||||||
|
* RangeError sur un kAjuste qui n'est pas un nombre fini > 0, null compris :
|
||||||
|
* 0,5 × null vaudrait 0, et la borne basse disparaîtrait sans bruit.
|
||||||
|
* @param {number} kAjuste
|
||||||
|
* @returns {{kMin: number, kMax: number}}
|
||||||
|
*/
|
||||||
|
export function bornesZoom(kAjuste) {
|
||||||
|
if (typeof kAjuste !== 'number' || !Number.isFinite(kAjuste) || kAjuste <= 0) {
|
||||||
|
throw new RangeError(`k ajusté : nombre fini > 0 attendu, reçu ${String(kAjuste)}`);
|
||||||
|
}
|
||||||
|
return { kMin: FACTEUR_K_MIN * kAjuste, kMax: K_MAX };
|
||||||
|
}
|
||||||
|
|
||||||
|
// k ramené dans les bornes.
|
||||||
|
const dansBornes = (k, bornes) => Math.min(bornes.kMax, Math.max(bornes.kMin, k));
|
||||||
|
|
||||||
|
// La vue d'échelle k dont le point écran (xCadre, yCadre), relatif au coin du
|
||||||
|
// cadre, montre le même point de dessin que dans vue.
|
||||||
|
function versK(vue, xCadre, yCadre, k) {
|
||||||
|
const x = (xCadre - vue.tx) / vue.k;
|
||||||
|
const y = (yCadre - vue.ty) / vue.k;
|
||||||
|
return { k, tx: xCadre - k * x, ty: yCadre - k * y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La vue, son k ramené dans les bornes, le point de dessin au centre du cadre
|
||||||
|
* restant au centre ; inchangée quand k y est déjà. Un cadrage mémorisé,
|
||||||
|
* restitué dans un autre cadre, y passe.
|
||||||
|
* @param {Vue} vue @param {Cadre} cadre @param {{kMin: number, kMax: number}} bornes
|
||||||
|
* @returns {Vue}
|
||||||
|
*/
|
||||||
|
export function borner(vue, cadre, bornes) {
|
||||||
|
const k = dansBornes(vue.k, bornes);
|
||||||
|
if (k === vue.k) return { k: vue.k, tx: vue.tx, ty: vue.ty };
|
||||||
|
return versK(vue, cadre.largeur / 2, cadre.hauteur / 2, k);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zoom d'un facteur sous le point écran (xEcran, yEcran) : k devient
|
||||||
|
* k × facteur ramené dans les bornes, puis la translation se calcule sur ce k
|
||||||
|
* borné, de sorte que le point de dessin sous le pointeur y reste, à une borne
|
||||||
|
* comprise. Lève RangeError sur un facteur qui n'est pas un nombre fini > 0.
|
||||||
|
* @param {Vue} vue @param {Cadre} cadre @param {number} facteur
|
||||||
|
* @param {{kMin: number, kMax: number}} bornes
|
||||||
|
* @returns {Vue}
|
||||||
|
*/
|
||||||
|
export function zoomerSous(vue, cadre, xEcran, yEcran, facteur, bornes) {
|
||||||
|
if (!Number.isFinite(facteur) || facteur <= 0) {
|
||||||
|
throw new RangeError(`facteur de zoom : nombre fini > 0 attendu, reçu ${String(facteur)}`);
|
||||||
|
}
|
||||||
|
return versK(vue, xEcran - cadre.gauche, yEcran - cadre.haut, dansBornes(vue.k * facteur, bornes));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position d'une table glissée, en cm : positionInitiale + (pointeur −
|
||||||
|
* pointeurInitial) / k. Le calcul se fait en écart : la table garde le
|
||||||
|
* décalage où le pointeur l'a saisie, et un mouvement nul la laisse en place.
|
||||||
|
* @param {Point} positionInitiale cm @param {Point} pointeurInitial px
|
||||||
|
* @param {Point} pointeur px @param {number} k
|
||||||
|
* @returns {Point}
|
||||||
|
*/
|
||||||
|
export function deplacer(positionInitiale, pointeurInitial, pointeur, k) {
|
||||||
|
return {
|
||||||
|
x: positionInitiale.x + (pointeur.x - pointeurInitial.x) / k,
|
||||||
|
y: positionInitiale.y + (pointeur.y - pointeurInitial.y) / k,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue glissée par le sol : tx, ty + (pointeur − pointeurInitial), en px ; k
|
||||||
|
* inchangé.
|
||||||
|
* @param {Vue} vueInitiale @param {Point} pointeurInitial @param {Point} pointeur
|
||||||
|
* @returns {Vue}
|
||||||
|
*/
|
||||||
|
export function deplacerVue(vueInitiale, pointeurInitial, pointeur) {
|
||||||
|
return {
|
||||||
|
k: vueInitiale.k,
|
||||||
|
tx: vueInitiale.tx + (pointeur.x - pointeurInitial.x),
|
||||||
|
ty: vueInitiale.ty + (pointeur.y - pointeurInitial.y),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Facteur de zoom d'un événement de molette : FACTEUR_CRAN puissance le
|
||||||
|
* nombre de crans, un cran valant 100 px, 3 lignes ou une page selon deltaMode
|
||||||
|
* (0, 1, 2) ; > 1 vers le haut (deltaY négatif), 1 sans mouvement. Lève
|
||||||
|
* RangeError sur un autre deltaMode.
|
||||||
|
* @param {number} deltaY @param {number} deltaMode
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function facteurMolette(deltaY, deltaMode) {
|
||||||
|
const cran = CRAN_PAR_MODE[deltaMode];
|
||||||
|
if (!Number.isInteger(deltaMode) || cran === undefined) {
|
||||||
|
throw new RangeError(`deltaMode : 0, 1 ou 2 attendu, reçu ${String(deltaMode)}`);
|
||||||
|
}
|
||||||
|
return FACTEUR_CRAN ** (-deltaY / cran);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valeur de l'attribut transform du groupe [data-vue] : chaque nombre écrit
|
||||||
|
* par String, que Number relit à l'identique.
|
||||||
|
* @param {Vue} vue
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function transformDe(vue) {
|
||||||
|
return `translate(${String(vue.tx)},${String(vue.ty)}) scale(${String(vue.k)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un nombre tel que String l'écrit : signe, chiffres, partie décimale,
|
||||||
|
// exposant.
|
||||||
|
const NOMBRE = String.raw`-?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?`;
|
||||||
|
const FORME_TRANSFORM = new RegExp(`^translate\\((${NOMBRE}),(${NOMBRE})\\) scale\\((${NOMBRE})\\)$`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La vue que décrit un attribut transform de la forme qu'écrit transformDe.
|
||||||
|
* Lève SyntaxError sur toute autre forme, et sur un k qui n'est pas > 0.
|
||||||
|
* @param {string} texte
|
||||||
|
* @returns {Vue}
|
||||||
|
*/
|
||||||
|
export function vueDepuisTransform(texte) {
|
||||||
|
const lu = FORME_TRANSFORM.exec(texte);
|
||||||
|
const k = lu === null ? Number.NaN : Number(lu[3]);
|
||||||
|
if (lu === null || !(k > 0) || !Number.isFinite(k)) {
|
||||||
|
throw new SyntaxError(`transform de vue attendu, reçu ${JSON.stringify(texte)}`);
|
||||||
|
}
|
||||||
|
return { k, tx: Number(lu[1]), ty: Number(lu[2]) };
|
||||||
|
}
|
||||||
348
src/geometrie/vue.test.js
Normal file
348
src/geometrie/vue.test.js
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Épreuves de la vue du plan (§ 7.2, § 14.10) : la conversion écran ↔ dessin,
|
||||||
|
// l'ajustement, les bornes relatives du zoom, le zoom sous le curseur, le
|
||||||
|
// glissement en écart, la molette et l'attribut transform. Chaque valeur
|
||||||
|
// attendue est écrite en clair ou se déduit, à la main, de la formule du
|
||||||
|
// § 7.2 ; les formules fausses qu'une épreuve doit refuser sont écrites ici,
|
||||||
|
// et montrées rouges sur les mêmes cas.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
|
import { FLUX, creerPcg32 } from '../demo/prng.js';
|
||||||
|
import {
|
||||||
|
FACTEUR_CRAN,
|
||||||
|
FACTEUR_K_MIN,
|
||||||
|
K_MAX,
|
||||||
|
K_PLAN_VIDE,
|
||||||
|
MARGE_AJUSTEMENT_PX,
|
||||||
|
ajuster,
|
||||||
|
borner,
|
||||||
|
bornesZoom,
|
||||||
|
deplacer,
|
||||||
|
deplacerVue,
|
||||||
|
dessinDepuisEcran,
|
||||||
|
ecranDepuisDessin,
|
||||||
|
facteurMolette,
|
||||||
|
kAjuste,
|
||||||
|
transformDe,
|
||||||
|
vueDepuisTransform,
|
||||||
|
zoomerSous,
|
||||||
|
} from './vue.js';
|
||||||
|
|
||||||
|
const PRECISION = 1e-9;
|
||||||
|
|
||||||
|
// Vrai quand a et b diffèrent de moins de PRECISION, relativement à leur
|
||||||
|
// grandeur quand elle dépasse 1.
|
||||||
|
const proches = (a, b) => Math.abs(a - b) <= PRECISION * Math.max(1, Math.abs(a), Math.abs(b));
|
||||||
|
|
||||||
|
function egauxPoints(recu, attendu, message) {
|
||||||
|
assert.ok(
|
||||||
|
proches(recu.x, attendu.x) && proches(recu.y, attendu.y),
|
||||||
|
`${message} : (${recu.x}, ${recu.y}) au lieu de (${attendu.x}, ${attendu.y})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function egalesVues(recue, attendue, message) {
|
||||||
|
assert.ok(
|
||||||
|
proches(recue.k, attendue.k) && proches(recue.tx, attendue.tx) && proches(recue.ty, attendue.ty),
|
||||||
|
`${message} : ${JSON.stringify(recue)} au lieu de ${JSON.stringify(attendue)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un englobant carré de côté c cm, coin haut gauche à l'origine.
|
||||||
|
const carre = (c) => ({ minX: 0, minY: 0, maxX: c, maxY: c });
|
||||||
|
|
||||||
|
describe('vue : conversion écran ↔ dessin (§ 7.2)', () => {
|
||||||
|
const VUE = { k: 2, tx: 10, ty: 20 };
|
||||||
|
const CADRE = { gauche: 100, haut: 50, largeur: 800, hauteur: 600 };
|
||||||
|
|
||||||
|
// Cas écrits à la main : [vue, cadre, point écran, point de dessin].
|
||||||
|
const CAS = [
|
||||||
|
[VUE, CADRE, { x: 150, y: 90 }, { x: 20, y: 10 }],
|
||||||
|
[{ k: 0.19, tx: -50, ty: 30 }, { gauche: 37, haut: 211, largeur: 1366, hauteur: 700 }, { x: 67, y: 279 }, { x: 80 / 0.19, y: 38 / 0.19 }],
|
||||||
|
[{ k: 10, tx: 0, ty: 0 }, { gauche: 37, haut: 211, largeur: 300, hauteur: 900 }, { x: 37, y: 211 }, { x: 0, y: 0 }],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Écarts d'une conversion aux cas écrits à la main, un par cas faux.
|
||||||
|
const ecarts = (conversion) =>
|
||||||
|
CAS.filter(([vue, cadre, ecran, dessin]) => {
|
||||||
|
const recu = conversion(vue, cadre, ecran.x, ecran.y);
|
||||||
|
return !(proches(recu.x, dessin.x) && proches(recu.y, dessin.y));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le point écran (150, 90) est le point de dessin (20, 10), et réciproquement', () => {
|
||||||
|
egauxPoints(dessinDepuisEcran(VUE, CADRE, 150, 90), { x: 20, y: 10 }, 'dessin');
|
||||||
|
egauxPoints(ecranDepuisDessin(VUE, CADRE, 20, 10), { x: 150, y: 90 }, 'écran');
|
||||||
|
assert.deepEqual(ecarts(dessinDepuisEcran), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('trois formules fausses tombent sur les mêmes cas : cadre oublié, division avant la translation, répartition dans un viewBox', () => {
|
||||||
|
const cadreOublie = (vue, cadre, x, y) => ({ x: (x - vue.tx) / vue.k, y: (y - vue.ty) / vue.k });
|
||||||
|
const diviseAvant = (vue, cadre, x, y) => ({
|
||||||
|
x: (x - cadre.gauche) / vue.k - vue.tx,
|
||||||
|
y: (y - cadre.haut) / vue.k - vue.ty,
|
||||||
|
});
|
||||||
|
// Un viewBox carré, de la largeur visible du dessin : le pointeur s'y
|
||||||
|
// répartit en proportion du cadre, juste en x, faux en y dès que le cadre
|
||||||
|
// n'est pas carré.
|
||||||
|
const viewBox = (vue, cadre, x, y) => {
|
||||||
|
const cote = cadre.largeur / vue.k;
|
||||||
|
return {
|
||||||
|
x: -vue.tx / vue.k + ((x - cadre.gauche) / cadre.largeur) * cote,
|
||||||
|
y: -vue.ty / vue.k + ((y - cadre.haut) / cadre.hauteur) * cote,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
for (const [nom, faux] of [['cadre oublié', cadreOublie], ['division avant', diviseAvant], ['viewBox', viewBox]]) {
|
||||||
|
assert.ok(ecarts(faux).length > 0, nom);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("l'aller-retour écran → dessin → écran rend le point, sur 25 points, quatre k et un cadre décalé", () => {
|
||||||
|
const cadre = { gauche: 37, haut: 211, largeur: 1366, hauteur: 700 };
|
||||||
|
let comptes = 0;
|
||||||
|
for (const k of [0.1, 0.19, 1, 10]) {
|
||||||
|
const vue = { k, tx: -123.25, ty: 47.5 };
|
||||||
|
for (let i = 0; i < 5; i += 1) {
|
||||||
|
for (let j = 0; j < 5; j += 1) {
|
||||||
|
const ecran = { x: cadre.gauche + (i * cadre.largeur) / 4, y: cadre.haut + (j * cadre.hauteur) / 4 };
|
||||||
|
const dessin = dessinDepuisEcran(vue, cadre, ecran.x, ecran.y);
|
||||||
|
egauxPoints(ecranDepuisDessin(vue, cadre, dessin.x, dessin.y), ecran, `k = ${k}, (${i}, ${j})`);
|
||||||
|
comptes += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(comptes, 100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("vue : l'ajustement et les bornes relatives du zoom (§ 7.2, § 14.10)", () => {
|
||||||
|
test('un plan de 36 m dans 1 366 × 700 s\'ajuste sous 0,2 px/cm, et k_min vaut 0,5 × k_ajusté sur deux plans', () => {
|
||||||
|
const cadre = { gauche: 0, haut: 0, largeur: 1366, hauteur: 700 };
|
||||||
|
const grand = kAjuste(carre(3600), cadre);
|
||||||
|
assert.ok(proches(grand, (700 - 32) / 3600), `k ajusté ${grand}`);
|
||||||
|
assert.ok(grand < 0.2);
|
||||||
|
assert.equal(bornesZoom(grand).kMin, 0.5 * grand);
|
||||||
|
assert.ok(bornesZoom(grand).kMin < 0.2);
|
||||||
|
const petit = kAjuste(carre(400), cadre);
|
||||||
|
assert.ok(proches(petit, 668 / 400), `k ajusté ${petit}`);
|
||||||
|
assert.equal(bornesZoom(petit).kMin, 0.5 * petit);
|
||||||
|
assert.equal(bornesZoom(petit).kMax, 10);
|
||||||
|
assert.equal(FACTEUR_K_MIN, 0.5);
|
||||||
|
assert.equal(K_MAX, 10);
|
||||||
|
assert.equal(MARGE_AJUSTEMENT_PX, 16);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('k_max vaut 10 : un zoom avant de facteur 2 depuis k = 9 rend 10', () => {
|
||||||
|
const cadre = { gauche: 0, haut: 0, largeur: 800, hauteur: 600 };
|
||||||
|
const vue = zoomerSous({ k: 9, tx: 0, ty: 0 }, cadre, 400, 300, 2, bornesZoom(1));
|
||||||
|
assert.equal(vue.k, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ajuster centre l'englobant au k ajusté ; le plan vide prend 1 px/cm, l'origine au centre", () => {
|
||||||
|
const cadre = { gauche: 0, haut: 0, largeur: 1366, hauteur: 700 };
|
||||||
|
const vue = ajuster(carre(3600), cadre);
|
||||||
|
assert.ok(proches(vue.k, 668 / 3600));
|
||||||
|
egauxPoints(ecranDepuisDessin(vue, cadre, 1800, 1800), { x: 683, y: 350 }, 'centre');
|
||||||
|
const decale = { gauche: 37, haut: 211, largeur: 1366, hauteur: 700 };
|
||||||
|
egauxPoints(ecranDepuisDessin(ajuster(carre(3600), decale), decale, 1800, 1800), { x: 37 + 683, y: 211 + 350 }, 'centre, cadre décalé');
|
||||||
|
|
||||||
|
const vide = { gauche: 5, haut: 7, largeur: 800, hauteur: 600 };
|
||||||
|
assert.equal(K_PLAN_VIDE, 1);
|
||||||
|
assert.equal(kAjuste(null, vide), 1);
|
||||||
|
assert.deepEqual(ajuster(null, vide), { k: 1, tx: 400, ty: 300 });
|
||||||
|
assert.deepEqual(bornesZoom(kAjuste(null, vide)), { kMin: 0.5, kMax: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un englobant de 10 × 10 cm s'ajuste à k = 10, plafonné ; un englobant réduit à un point aussi", () => {
|
||||||
|
const cadre = { gauche: 0, haut: 0, largeur: 800, hauteur: 600 };
|
||||||
|
assert.equal(kAjuste(carre(10), cadre), 10);
|
||||||
|
assert.equal(ajuster(carre(10), cadre).k, 10);
|
||||||
|
assert.equal(kAjuste({ minX: 300, minY: 300, maxX: 300, maxY: 300 }, cadre), 10);
|
||||||
|
const ligne = ajuster({ minX: 0, minY: 300, maxX: 3000, maxY: 300 }, cadre);
|
||||||
|
assert.ok(proches(ligne.k, 768 / 3000));
|
||||||
|
egauxPoints(ecranDepuisDessin(ligne, cadre, 1500, 300), { x: 400, y: 300 }, 'centre');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un cadre nul ou plus petit que ses marges ne s'ajuste pas : null, plan vide compris ; 33 px de large suffisent", () => {
|
||||||
|
for (const [largeur, hauteur] of [[0, 600], [800, 0], [0, 0], [20, 20], [32, 600], [800, 32]]) {
|
||||||
|
const cadre = { gauche: 0, haut: 0, largeur, hauteur };
|
||||||
|
assert.equal(kAjuste(carre(3600), cadre), null, `${largeur} × ${hauteur}`);
|
||||||
|
assert.equal(kAjuste(null, cadre), null, `${largeur} × ${hauteur}, plan vide`);
|
||||||
|
assert.equal(ajuster(carre(3600), cadre), null, `${largeur} × ${hauteur}`);
|
||||||
|
assert.equal(ajuster(null, cadre), null, `${largeur} × ${hauteur}, plan vide`);
|
||||||
|
}
|
||||||
|
const etroit = { gauche: 0, haut: 0, largeur: 33, hauteur: 600 };
|
||||||
|
assert.ok(proches(kAjuste(carre(3600), etroit), 1 / 3600));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bornesZoom refuse un k ajusté qui n\'est pas un nombre fini positif', () => {
|
||||||
|
for (const k of [null, undefined, 0, -1, Number.NaN, Number.POSITIVE_INFINITY, '1']) {
|
||||||
|
assert.throws(() => bornesZoom(k), RangeError, String(k));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('200 englobants et cadres tirés, nuls et vides compris : chaque nombre rendu est fini, chaque k > 0, ou le résultat vaut null', () => {
|
||||||
|
const GRAINE = 20260;
|
||||||
|
const alea = creerPcg32(GRAINE, FLUX.DEMO);
|
||||||
|
let nuls = 0;
|
||||||
|
let rendus = 0;
|
||||||
|
for (let i = 0; i < 200; i += 1) {
|
||||||
|
const nature = i % 5;
|
||||||
|
const cadre = {
|
||||||
|
gauche: alea.borne(2000) - 1000,
|
||||||
|
haut: alea.borne(2000) - 1000,
|
||||||
|
largeur: nature === 0 ? alea.borne(33) : alea.borne(3000),
|
||||||
|
hauteur: nature === 1 ? 0 : alea.borne(3000),
|
||||||
|
};
|
||||||
|
let englobant = null;
|
||||||
|
if (nature >= 2) {
|
||||||
|
const minX = alea.borne(20000) - 10000;
|
||||||
|
const minY = alea.borne(20000) - 10000;
|
||||||
|
const plat = nature === 2;
|
||||||
|
englobant = {
|
||||||
|
minX,
|
||||||
|
minY,
|
||||||
|
maxX: minX + (plat ? 0 : alea.borne(10000) / 7),
|
||||||
|
maxY: minY + (plat ? 0 : alea.borne(10000) / 3),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const k = kAjuste(englobant, cadre);
|
||||||
|
const vue = ajuster(englobant, cadre);
|
||||||
|
if (k === null) {
|
||||||
|
assert.equal(vue, null, `tirage ${i}`);
|
||||||
|
nuls += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rendus += 1;
|
||||||
|
assert.ok(Number.isFinite(k) && k > 0 && k <= K_MAX, `tirage ${i} : k ${k}`);
|
||||||
|
assert.ok([vue.k, vue.tx, vue.ty].every(Number.isFinite) && vue.k > 0, `tirage ${i} : ${JSON.stringify(vue)}`);
|
||||||
|
const bornes = bornesZoom(k);
|
||||||
|
assert.ok(Number.isFinite(bornes.kMin) && bornes.kMin > 0, `tirage ${i}`);
|
||||||
|
}
|
||||||
|
assert.ok(nuls > 0 && rendus > 0, `${nuls} nuls, ${rendus} rendus`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un cadrage hors des bornes est ramené dans elles, le centre du cadre fixe', () => {
|
||||||
|
const cadre = { gauche: 30, haut: 40, largeur: 800, hauteur: 600 };
|
||||||
|
const k = kAjuste({ minX: 0, minY: 0, maxX: 1000, maxY: 1420 }, cadre);
|
||||||
|
assert.ok(proches(k, 0.4));
|
||||||
|
const bornes = bornesZoom(k);
|
||||||
|
const memorise = { k: 0.1, tx: -25, ty: 60 };
|
||||||
|
const centre = dessinDepuisEcran(memorise, cadre, 30 + 400, 40 + 300);
|
||||||
|
const ramene = borner(memorise, cadre, bornes);
|
||||||
|
assert.ok(proches(ramene.k, 0.2), `k ${ramene.k}`);
|
||||||
|
egauxPoints(dessinDepuisEcran(ramene, cadre, 30 + 400, 40 + 300), centre, 'centre');
|
||||||
|
const trop = borner({ k: 25, tx: 3, ty: 4 }, cadre, bornes);
|
||||||
|
assert.equal(trop.k, 10);
|
||||||
|
assert.deepEqual(borner({ k: 1, tx: 3, ty: 4 }, cadre, bornes), { k: 1, tx: 3, ty: 4 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('vue : le zoom sous le curseur (§ 7.2)', () => {
|
||||||
|
const CADRE = { gauche: 37, haut: 211, largeur: 800, hauteur: 600 };
|
||||||
|
const DEPART = { k: 1, tx: 50, ty: 40 };
|
||||||
|
const BORNES = { kMin: 0.2, kMax: 10 };
|
||||||
|
const POINTS = [
|
||||||
|
{ x: 37 + 400, y: 211 + 300 },
|
||||||
|
{ x: 37 + 799, y: 211 + 599 },
|
||||||
|
{ x: 37, y: 211 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Les cas où le point de dessin sous le pointeur bouge, pour une fonction
|
||||||
|
// de zoom donnée.
|
||||||
|
const fuites = (zoomer) =>
|
||||||
|
[1.1, 1 / 1.1, 100, 0.001].flatMap((facteur) =>
|
||||||
|
POINTS.filter((p) => {
|
||||||
|
const avant = dessinDepuisEcran(DEPART, CADRE, p.x, p.y);
|
||||||
|
const apres = dessinDepuisEcran(zoomer(DEPART, CADRE, p.x, p.y, facteur, BORNES), CADRE, p.x, p.y);
|
||||||
|
return !(proches(avant.x, apres.x) && proches(avant.y, apres.y));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
test('le point de dessin sous le pointeur y reste, au centre comme au coin, aux bornes comprises', () => {
|
||||||
|
assert.deepEqual(fuites(zoomerSous), []);
|
||||||
|
assert.equal(zoomerSous(DEPART, CADRE, 437, 511, 100, BORNES).k, 10);
|
||||||
|
assert.equal(zoomerSous(DEPART, CADRE, 437, 511, 0.001, BORNES).k, 0.2);
|
||||||
|
assert.ok(proches(zoomerSous(DEPART, CADRE, 437, 511, 1.1, BORNES).k, 1.1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deux zooms faux tombent : autour de l'origine du cadre, et le bornage appliqué après coup", () => {
|
||||||
|
const borne = (k) => Math.min(BORNES.kMax, Math.max(BORNES.kMin, k));
|
||||||
|
const autourDeLOrigine = (vue, cadre, x, y, facteur, bornes) => ({ k: borne(vue.k * facteur), tx: vue.tx, ty: vue.ty });
|
||||||
|
const borneApres = (vue, cadre, x, y, facteur) => {
|
||||||
|
const k = vue.k * facteur;
|
||||||
|
const p = dessinDepuisEcran(vue, cadre, x, y);
|
||||||
|
return { k: borne(k), tx: x - cadre.gauche - k * p.x, ty: y - cadre.haut - k * p.y };
|
||||||
|
};
|
||||||
|
assert.ok(fuites(autourDeLOrigine).length > 0);
|
||||||
|
assert.ok(fuites(borneApres).length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un facteur qui n\'est pas un nombre fini positif est refusé', () => {
|
||||||
|
for (const facteur of [0, -1.1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||||
|
assert.throws(() => zoomerSous(DEPART, CADRE, 437, 511, facteur, BORNES), RangeError, String(facteur));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('vue : le glissement se calcule en écart (§ 7.2)', () => {
|
||||||
|
test("une table saisie à 37 px de son centre suit l'écart du pointeur, divisé par k", () => {
|
||||||
|
const initiale = { x: 300, y: 300 };
|
||||||
|
const pointeurInitial = { x: 437, y: 260 };
|
||||||
|
assert.deepEqual(deplacer(initiale, pointeurInitial, { x: 477, y: 240 }, 2), { x: 320, y: 290 });
|
||||||
|
assert.deepEqual(deplacer(initiale, pointeurInitial, pointeurInitial, 2), initiale);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("la mutation qui met l'origine de la table sous le pointeur tombe", () => {
|
||||||
|
// Vue où le centre de la table, (300, 300) cm, est à l'écran (400, 260) :
|
||||||
|
// le pointeur saisi en (437, 260) est à 37 px à droite du centre.
|
||||||
|
const vue = { k: 2, tx: -200, ty: -340 };
|
||||||
|
const cadre = { gauche: 0, haut: 0, largeur: 800, hauteur: 600 };
|
||||||
|
egauxPoints(ecranDepuisDessin(vue, cadre, 300, 300), { x: 400, y: 260 }, 'centre');
|
||||||
|
const sousLePointeur = (initiale, pointeurInitial, pointeur) => dessinDepuisEcran(vue, cadre, pointeur.x, pointeur.y);
|
||||||
|
const initiale = { x: 300, y: 300 };
|
||||||
|
const pointeurInitial = { x: 437, y: 260 };
|
||||||
|
assert.notDeepEqual(sousLePointeur(initiale, pointeurInitial, pointeurInitial, 2), initiale);
|
||||||
|
assert.notDeepEqual(sousLePointeur(initiale, pointeurInitial, { x: 477, y: 240 }, 2), { x: 320, y: 290 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("glisser le sol ajoute l'écart du pointeur, en px, et garde k", () => {
|
||||||
|
assert.deepEqual(deplacerVue({ k: 0.5, tx: 10, ty: -20 }, { x: 100, y: 100 }, { x: 130, y: 85 }), { k: 0.5, tx: 40, ty: -35 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("vue : la molette et l'attribut transform", () => {
|
||||||
|
test('un cran vaut 100 px, 3 lignes ou une page ; vers le haut, le facteur dépasse 1', () => {
|
||||||
|
assert.equal(FACTEUR_CRAN, 1.1);
|
||||||
|
assert.ok(proches(facteurMolette(-100, 0), 1.1));
|
||||||
|
assert.ok(proches(facteurMolette(100, 0), 1 / 1.1));
|
||||||
|
assert.ok(proches(facteurMolette(-3, 1), 1.1));
|
||||||
|
assert.ok(proches(facteurMolette(1, 2), 1 / 1.1));
|
||||||
|
assert.ok(proches(facteurMolette(-250, 0), 1.1 ** 2.5));
|
||||||
|
assert.equal(facteurMolette(0, 0), 1);
|
||||||
|
assert.throws(() => facteurMolette(-100, 3), RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('transformDe et vueDepuisTransform font l\'aller-retour ; une autre forme lève', () => {
|
||||||
|
const vues = [
|
||||||
|
{ k: 2, tx: 10, ty: 20 },
|
||||||
|
{ k: 1 / 3600, tx: -683.5, ty: 0.1 + 0.2 },
|
||||||
|
{ k: 10, tx: 1e-7, ty: -2.5e21 },
|
||||||
|
];
|
||||||
|
assert.equal(transformDe(vues[0]), 'translate(10,20) scale(2)');
|
||||||
|
for (const vue of vues) assert.deepEqual(vueDepuisTransform(transformDe(vue)), vue);
|
||||||
|
for (const texte of [
|
||||||
|
'',
|
||||||
|
'translate(10,20)',
|
||||||
|
'scale(2) translate(10,20)',
|
||||||
|
'translate(10 20) scale(2)',
|
||||||
|
'translate(10,20) scale(0)',
|
||||||
|
'translate(10,20) scale(-2)',
|
||||||
|
'translate(a,20) scale(2)',
|
||||||
|
'translate(10,20) scale(2) rotate(3)',
|
||||||
|
]) {
|
||||||
|
assert.throws(() => vueDepuisTransform(texte), SyntaxError, texte);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -9,21 +9,22 @@
|
||||||
// contraste se calcule sur les couleurs calculées, qui ne sont les couleurs
|
// contraste se calcule sur les couleurs calculées, qui ne sont les couleurs
|
||||||
// peintes que sans opacité partielle, filtre ni mode de fusion — ce qu'une
|
// peintes que sans opacité partielle, filtre ni mode de fusion — ce qu'une
|
||||||
// épreuve vérifie dans l'ascendance de chaque texte (§ 14.5). Les épreuves de
|
// épreuve vérifie dans l'ascendance de chaque texte (§ 14.5). Les épreuves de
|
||||||
// visibilité, de contraste et d'opacité passent par les deux thèmes : le
|
// visibilité, de contraste et d'opacité passent par les deux thèmes, que
|
||||||
// protocole de débogage de Chromium (CDP) émule prefers-color-scheme.
|
// l'attribut data-theme de <html> choisit (§ 19.2). Le contraste est celui de
|
||||||
|
// palette.js, seule formule des WCAG du dépôt.
|
||||||
//
|
//
|
||||||
// Le projet navigateur n'importe pas node:assert/strict : côté navigateur,
|
// Le projet navigateur n'importe pas node:assert/strict : côté navigateur,
|
||||||
// Vite le remplace par un module qui lève une erreur à tout accès. Il emploie
|
// Vite le remplace par un module qui lève une erreur à tout accès. Il emploie
|
||||||
// expect, et prend test et describe chez Vitest plutôt que dans
|
// expect, et prend test et describe chez Vitest plutôt que dans
|
||||||
// test/lanceur.js, dont le repli vers node:test ne vaut que pour la série node.
|
// test/lanceur.js, dont le repli vers node:test ne vaut que pour la série node.
|
||||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||||
import { cdp } from 'vitest/browser';
|
|
||||||
import { flushSync, mount, unmount } from 'svelte';
|
import { flushSync, mount, unmount } from 'svelte';
|
||||||
import App from './App.svelte';
|
import App from './App.svelte';
|
||||||
|
import { contraste } from './palette.js';
|
||||||
import { VERSION } from '../version.genere.js';
|
import { VERSION } from '../version.genere.js';
|
||||||
|
|
||||||
const TRANSPARENT = 'rgba(0, 0, 0, 0)';
|
const TRANSPARENT = 'rgba(0, 0, 0, 0)';
|
||||||
const THEMES = ['light', 'dark'];
|
const THEMES = ['clair', 'sombre'];
|
||||||
|
|
||||||
// Composantes [r, g, b, a] d'une couleur calculée. Chromium la rend sous la
|
// Composantes [r, g, b, a] d'une couleur calculée. Chromium la rend sous la
|
||||||
// forme « rgb(r, g, b) » ou « rgba(r, g, b, a) » quand la source est une
|
// forme « rgb(r, g, b) » ou « rgba(r, g, b, a) » quand la source est une
|
||||||
|
|
@ -38,21 +39,6 @@ function composantes(couleur) {
|
||||||
return [Number(m[1]), Number(m[2]), Number(m[3]), alpha];
|
return [Number(m[1]), Number(m[2]), Number(m[3]), alpha];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Luminance relative d'une couleur sRGB, au sens des WCAG.
|
|
||||||
function luminance([r, g, b]) {
|
|
||||||
const lineaire = (c) => {
|
|
||||||
const s = c / 255;
|
|
||||||
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
||||||
};
|
|
||||||
return 0.2126 * lineaire(r) + 0.7152 * lineaire(g) + 0.0722 * lineaire(b);
|
|
||||||
}
|
|
||||||
|
|
||||||
function contraste(a, b) {
|
|
||||||
const la = luminance(a);
|
|
||||||
const lb = luminance(b);
|
|
||||||
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
// L'élément, puis chacun de ses ancêtres jusqu'à la racine du document.
|
// L'élément, puis chacun de ses ancêtres jusqu'à la racine du document.
|
||||||
function ascendance(element) {
|
function ascendance(element) {
|
||||||
const chaine = [];
|
const chaine = [];
|
||||||
|
|
@ -89,20 +75,21 @@ function designation(element) {
|
||||||
return classe ? `${element.localName}.${classe}` : element.localName;
|
return classe ? `${element.localName}.${classe}` : element.localName;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exécute mesure() sous le thème demandé et rend sa valeur. L'émulation est
|
// Exécute mesure() sous le thème demandé, posé par data-theme sur <html>, et
|
||||||
// levée ensuite, même quand mesure() échoue : le test suivant retrouve la
|
// rend sa valeur. L'attribut retrouve ensuite sa valeur d'avant, ou son
|
||||||
// préférence propre du navigateur.
|
// absence, même quand mesure() lève : le test suivant part du même document.
|
||||||
async function sousTheme(theme, mesure) {
|
async function sousTheme(theme, mesure) {
|
||||||
await cdp().send('Emulation.setEmulatedMedia', {
|
const racine = document.documentElement;
|
||||||
features: [{ name: 'prefers-color-scheme', value: theme }],
|
const avant = racine.getAttribute('data-theme');
|
||||||
});
|
racine.setAttribute('data-theme', theme);
|
||||||
try {
|
try {
|
||||||
expect(matchMedia('(prefers-color-scheme: dark)').matches, theme).toBe(
|
return await mesure();
|
||||||
theme === 'dark',
|
|
||||||
);
|
|
||||||
return mesure();
|
|
||||||
} finally {
|
} finally {
|
||||||
await cdp().send('Emulation.setEmulatedMedia', { features: [] });
|
if (avant === null) {
|
||||||
|
racine.removeAttribute('data-theme');
|
||||||
|
} else {
|
||||||
|
racine.setAttribute('data-theme', avant);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -210,8 +197,8 @@ describe("bandeau d'application", () => {
|
||||||
});
|
});
|
||||||
couleursParTheme.push(couleurs);
|
couleursParTheme.push(couleurs);
|
||||||
}
|
}
|
||||||
// Une émulation sans effet, ou un thème sombre qui ne redéfinit aucune
|
// Un attribut que la feuille ne lit pas, ou un thème sombre qui ne
|
||||||
// couleur, ferait mesurer deux fois le thème clair.
|
// redéfinit aucune couleur, ferait mesurer deux fois le thème clair.
|
||||||
expect(
|
expect(
|
||||||
couleursParTheme[1],
|
couleursParTheme[1],
|
||||||
`mêmes couleurs dans les deux thèmes : ${couleursParTheme[0].join(' ; ')}`,
|
`mêmes couleurs dans les deux thèmes : ${couleursParTheme[0].join(' ; ')}`,
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,144 @@
|
||||||
/* © 2026 TechnoLibre (http://www.technolibre.ca)
|
/* © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) */
|
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) */
|
||||||
|
|
||||||
/* Jetons de l'interface : couleurs, typographie, espacements, rayons. Le thème
|
/* Jetons de l'interface, un seul fichier (§ 13.3) : couleurs, polices,
|
||||||
suit prefers-color-scheme ; chaque thème redéfinit les mêmes jetons de
|
échelle typographique, espacements, rayons, ombres. Les composants ne lisent
|
||||||
couleur, et les composants ne lisent que les jetons (§ 13.3).
|
que ces jetons ; aucune couleur n'est écrite ailleurs.
|
||||||
|
|
||||||
Chaque couleur de texte tient au moins 4,5:1 sur le fond où elle est peinte,
|
Deux thèmes, posés par l'attribut data-theme de <html>, que l'application
|
||||||
et aucune n'emploie de canal alpha : la couleur relue dans le style calculé
|
règle (§ 19.2) : :root[data-theme='clair'] et :root[data-theme='sombre']
|
||||||
est alors la couleur peinte (§ 14.5). */
|
définissent les mêmes jetons de couleur ; sans attribut, le clair
|
||||||
|
s'applique, son bloc portant aussi le sélecteur :root. Le sélecteur du
|
||||||
|
sombre est plus spécifique que :root seul : posé, il l'emporte. La feuille
|
||||||
|
ne lit pas la préférence du système.
|
||||||
|
|
||||||
|
La palette se valide par calcul (§ 12.7, § 14.5) : marques.js nomme, pour
|
||||||
|
chaque marque porteuse d'information et chaque texte, le jeton qui la peint
|
||||||
|
et ceux sur lesquels elle se peint ; palette.js mesure 3:1 pour une marque,
|
||||||
|
4,5:1 pour un texte, dans les deux thèmes, en couleurs d'origine puis sous
|
||||||
|
les trois dichromaties. Aucun jeton de texte ou de marque n'a de canal
|
||||||
|
alpha : la couleur relue dans le style calculé est alors la couleur peinte.
|
||||||
|
|
||||||
|
La teinte de --couleur-conflit est réservée (§ 12.7) : tout autre jeton
|
||||||
|
chromatique s'en tient à 30° au moins, et aucun ne porte sa valeur.
|
||||||
|
|
||||||
|
La hachure d'une place à pourvoir peint des traits --couleur-hachure sur le
|
||||||
|
fond de la chaise, --couleur-chaise-fond : ce sont ses deux couleurs. */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
|
||||||
|
|
||||||
--couleur-page-fond: #f6f7f9;
|
|
||||||
--couleur-page-texte: #1c2128;
|
|
||||||
--couleur-bandeau-fond: #1f3a5f;
|
|
||||||
--couleur-bandeau-texte: #ffffff;
|
|
||||||
--couleur-bandeau-filet: #8fb3d9;
|
|
||||||
|
|
||||||
--police-interface: system-ui, sans-serif;
|
--police-interface: system-ui, sans-serif;
|
||||||
|
|
||||||
|
/* Échelle typographique, du plus petit au plus grand. */
|
||||||
|
--taille-texte-petit: 0.8125rem;
|
||||||
--taille-texte-bandeau: 0.9375rem;
|
--taille-texte-bandeau: 0.9375rem;
|
||||||
|
--taille-texte: 1rem;
|
||||||
|
--taille-titre: 1.25rem;
|
||||||
|
--taille-titre-grand: 1.5rem;
|
||||||
|
|
||||||
--espace-1: 0.25rem;
|
--espace-1: 0.25rem;
|
||||||
--espace-2: 0.5rem;
|
--espace-2: 0.5rem;
|
||||||
--espace-3: 0.75rem;
|
--espace-3: 0.75rem;
|
||||||
--espace-4: 1rem;
|
--espace-4: 1rem;
|
||||||
|
--espace-5: 1.5rem;
|
||||||
|
--espace-6: 2rem;
|
||||||
|
|
||||||
--rayon-1: 0.25rem;
|
--rayon-1: 0.25rem;
|
||||||
|
--rayon-2: 0.5rem;
|
||||||
|
|
||||||
|
/* Ombres : une couleur opaque par thème, --couleur-ombre. */
|
||||||
|
--ombre-1: 0 1px 2px var(--couleur-ombre);
|
||||||
|
--ombre-2: 0 2px 6px var(--couleur-ombre);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
:root,
|
||||||
:root {
|
:root[data-theme='clair'] {
|
||||||
color-scheme: dark;
|
color-scheme: light;
|
||||||
|
|
||||||
--couleur-page-fond: #121519;
|
/* Page. */
|
||||||
--couleur-page-texte: #e3e6ea;
|
--couleur-page-fond: #f6f7f9;
|
||||||
--couleur-bandeau-fond: #1d2c3f;
|
--couleur-page-texte: #1c2128;
|
||||||
--couleur-bandeau-texte: #f2f4f7;
|
--couleur-page-texte-secondaire: #4b5563;
|
||||||
--couleur-bandeau-filet: #5b7fa6;
|
--couleur-page-filet: #c3c9d1;
|
||||||
}
|
--couleur-ombre: #b4bcc6;
|
||||||
|
|
||||||
|
/* Bandeau d'application. */
|
||||||
|
--couleur-bandeau-fond: #172b47;
|
||||||
|
--couleur-bandeau-texte: #ffffff;
|
||||||
|
--couleur-bandeau-filet: #8fb3d9;
|
||||||
|
|
||||||
|
/* Cadre de mode (§ 8.4). */
|
||||||
|
--couleur-cadre-lecture: #5c6878;
|
||||||
|
--couleur-cadre-ecriture: #2e7d32;
|
||||||
|
--couleur-cadre-bloque: #7b3fa0;
|
||||||
|
|
||||||
|
/* Messages. */
|
||||||
|
--couleur-refus: #6a1b9a;
|
||||||
|
--couleur-avis: #7a5000;
|
||||||
|
|
||||||
|
/* Désignation. Le focus, la sélection, la mise en évidence et le fantôme
|
||||||
|
passent sur une chaise vide comme sur une chaise pleine : leur clarté se
|
||||||
|
tient entre le fond et le plein de la chaise, à 3:1 de chacun. */
|
||||||
|
--couleur-focus: #8269e1;
|
||||||
|
--couleur-selection: #1a66f0;
|
||||||
|
--couleur-mise-en-evidence: #00897b;
|
||||||
|
|
||||||
|
/* Plan : sol, mobilier, marques. */
|
||||||
|
--couleur-sol: #eceef1;
|
||||||
|
--couleur-table-fond: #ffffff;
|
||||||
|
--couleur-table-trait: #4a5059;
|
||||||
|
--couleur-chaise-fond: #ffffff;
|
||||||
|
--couleur-chaise-trait: #4a5059;
|
||||||
|
--couleur-chaise-pleine: #1f1f1f;
|
||||||
|
--couleur-hachure: #1f1f1f;
|
||||||
|
--couleur-marque: #767676;
|
||||||
|
--couleur-conflit: #b3261e;
|
||||||
|
--couleur-pastille-fond: #2b2f36;
|
||||||
|
--couleur-pastille-texte: #ffffff;
|
||||||
|
--couleur-poignee: #4a5059;
|
||||||
|
--couleur-gel: #1565c0;
|
||||||
|
--couleur-liste-texte: #1c2128;
|
||||||
|
--couleur-echelle: #1c2128;
|
||||||
|
--couleur-fantome: #b040f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='sombre'] {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
--couleur-page-fond: #121519;
|
||||||
|
--couleur-page-texte: #e3e6ea;
|
||||||
|
--couleur-page-texte-secondaire: #a3acb8;
|
||||||
|
--couleur-page-filet: #3a414b;
|
||||||
|
--couleur-ombre: #000000;
|
||||||
|
|
||||||
|
--couleur-bandeau-fond: #1d2c3f;
|
||||||
|
--couleur-bandeau-texte: #f2f4f7;
|
||||||
|
--couleur-bandeau-filet: #5b7fa6;
|
||||||
|
|
||||||
|
--couleur-cadre-lecture: #9aa5b4;
|
||||||
|
--couleur-cadre-ecriture: #81c784;
|
||||||
|
--couleur-cadre-bloque: #ce93d8;
|
||||||
|
|
||||||
|
--couleur-refus: #d7a6f0;
|
||||||
|
--couleur-avis: #f0c060;
|
||||||
|
|
||||||
|
--couleur-focus: #8764e1;
|
||||||
|
--couleur-selection: #4070f0;
|
||||||
|
--couleur-mise-en-evidence: #00897b;
|
||||||
|
|
||||||
|
--couleur-sol: #1a1d21;
|
||||||
|
--couleur-table-fond: #262b31;
|
||||||
|
--couleur-table-trait: #9aa3ae;
|
||||||
|
--couleur-chaise-fond: #15181c;
|
||||||
|
--couleur-chaise-trait: #b0b8c2;
|
||||||
|
--couleur-chaise-pleine: #e8e8e8;
|
||||||
|
--couleur-hachure: #ededed;
|
||||||
|
--couleur-marque: #7a7a7a;
|
||||||
|
--couleur-conflit: #ff8a80;
|
||||||
|
--couleur-pastille-fond: #e6e8eb;
|
||||||
|
--couleur-pastille-texte: #15181c;
|
||||||
|
--couleur-poignee: #c5ccd6;
|
||||||
|
--couleur-gel: #90caf9;
|
||||||
|
--couleur-liste-texte: #e3e6ea;
|
||||||
|
--couleur-echelle: #e3e6ea;
|
||||||
|
--couleur-fantome: #b040f0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,64 @@
|
||||||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
// Table des chaînes visibles de l'interface. Un composant n'écrit aucune
|
// L'assemblage des textes des écrans (§ 14.6). Un composant n'écrit aucune
|
||||||
// chaîne visible en dur : il les lit ici, seul endroit qu'une traduction
|
// chaîne visible en dur : il les lit par la traduction, dont les tables
|
||||||
// remplace (§ 14.6).
|
// d'écran sont sous libelles/, une par écran, réunies ici dans l'ordre
|
||||||
|
// alphabétique de leurs fichiers ; aucune table n'importe cet assemblage.
|
||||||
|
//
|
||||||
|
// libelleProvenance, aUnLibelle et titreAvecVersion lisent en français la
|
||||||
|
// table de la coquille : le bandeau, le titre de la fenêtre, l'essai de
|
||||||
|
// démarrage et le livrable les appellent sans connaître la traduction.
|
||||||
|
import { creerTraduction } from '../application/traduction.js';
|
||||||
|
import { TABLE as ACCUEIL } from './libelles/accueil.js';
|
||||||
|
import { TABLE as COMMUNS } from './libelles/communs.js';
|
||||||
|
import { TABLE as COQUILLE } from './libelles/coquille.js';
|
||||||
|
import { TABLE as DIAGNOSTIC } from './libelles/diagnostic.js';
|
||||||
|
import { TABLE as EVENEMENT } from './libelles/evenement.js';
|
||||||
|
import { TABLE as GENERATION } from './libelles/generation.js';
|
||||||
|
import { TABLE as IMPORT } from './libelles/import.js';
|
||||||
|
import { TABLE as PARTICIPANTS } from './libelles/participants.js';
|
||||||
|
import { TABLE as PLAN } from './libelles/plan.js';
|
||||||
|
import { TABLE as PROPOSITIONS } from './libelles/propositions.js';
|
||||||
|
import { TABLE as TABLES } from './libelles/tables.js';
|
||||||
|
|
||||||
const NOM_APPLICATION = 'Gestion table tournante Libre';
|
/** Les tables de libelles/, dans l'ordre alphabétique de leurs fichiers. */
|
||||||
|
export const TABLES_INTERFACE = Object.freeze([
|
||||||
// Mode de construction de Vite → provenance affichée dans le bandeau
|
ACCUEIL,
|
||||||
// (§ 18.6). Une Map ne répond qu'aux clés qu'on y a posées : un mode nommé
|
COMMUNS,
|
||||||
// comme une propriété héritée d'Object ne rend pas une fonction.
|
COQUILLE,
|
||||||
const PROVENANCES = new Map([
|
DIAGNOSTIC,
|
||||||
['developpement', 'développement'],
|
EVENEMENT,
|
||||||
['livraison', 'livraison'],
|
GENERATION,
|
||||||
['documentation', 'documentation'],
|
IMPORT,
|
||||||
['test', 'épreuve'],
|
PARTICIPANTS,
|
||||||
|
PLAN,
|
||||||
|
PROPOSITIONS,
|
||||||
|
TABLES,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Libellé de la provenance pour un mode de Vite. Un mode sans libellé, comme
|
const { t } = creerTraduction(TABLES_INTERFACE);
|
||||||
// celui d'un « vite build » lancé sans --mode, s'affiche sous son propre nom.
|
|
||||||
export function libelleProvenance(mode) {
|
// La clé de la provenance d'un mode de Vite (§ 18.6). La table est une Map :
|
||||||
return PROVENANCES.get(mode) ?? mode;
|
// elle ne répond qu'aux clés qu'on y a posées, et un mode nommé comme une
|
||||||
}
|
// propriété héritée d'Object n'en trouve aucune.
|
||||||
|
const cleProvenance = (mode) => `coquille.provenance.${mode}`;
|
||||||
|
|
||||||
// Vrai quand le mode a un libellé déclaré. Comparer libelleProvenance(mode) à
|
// Vrai quand le mode a un libellé déclaré. Comparer libelleProvenance(mode) à
|
||||||
// mode ne le dit pas : « livraison » et « documentation » ont un libellé
|
// mode ne le dit pas : « livraison » et « documentation » ont un libellé
|
||||||
// identique à leur nom.
|
// identique à leur nom.
|
||||||
export function aUnLibelle(mode) {
|
export function aUnLibelle(mode) {
|
||||||
return PROVENANCES.has(mode);
|
return COQUILLE.has(cleProvenance(mode));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Libellé de la provenance pour un mode de Vite. Un mode sans libellé, comme
|
||||||
|
// celui d'un « vite build » lancé sans --mode, s'affiche sous son propre nom.
|
||||||
|
export function libelleProvenance(mode) {
|
||||||
|
return aUnLibelle(mode) ? t(cleProvenance(mode)) : mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nom de l'application suivi de la version affichée : texte du bandeau et
|
// Nom de l'application suivi de la version affichée : texte du bandeau et
|
||||||
// titre de la fenêtre (§ 18.6).
|
// titre de la fenêtre (§ 18.6).
|
||||||
export function titreAvecVersion(version) {
|
export function titreAvecVersion(version) {
|
||||||
return `${NOM_APPLICATION} — ${version}`;
|
return t('coquille.titre', { version });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,16 @@
|
||||||
// libellé de provenance, comme le mode test que pose Vitest ; un mode sans
|
// libellé de provenance, comme le mode test que pose Vitest ; un mode sans
|
||||||
// libellé s'affiche sous son propre nom plutôt que de laisser le bandeau muet
|
// libellé s'affiche sous son propre nom plutôt que de laisser le bandeau muet
|
||||||
// sur la provenance.
|
// sur la provenance.
|
||||||
|
//
|
||||||
|
// Les textes des écrans se partagent en tables, une par écran, sous
|
||||||
|
// libelles/ : chacune exporte TABLE, une Map de libellés, et libelles.js les
|
||||||
|
// assemble par ordre alphabétique des fichiers.
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import { TABLES_APPLICATION } from '../application/libelles.js';
|
||||||
|
import { creerTraduction } from '../application/traduction.js';
|
||||||
import { describe, test } from '../../test/lanceur.js';
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
import { aUnLibelle, libelleProvenance, titreAvecVersion } from './libelles.js';
|
import { TABLES_INTERFACE, aUnLibelle, libelleProvenance, titreAvecVersion } from './libelles.js';
|
||||||
|
|
||||||
// Modes nommés par les scripts de package.json, triés, sans doublon.
|
// Modes nommés par les scripts de package.json, triés, sans doublon.
|
||||||
function modesDesScripts() {
|
function modesDesScripts() {
|
||||||
|
|
@ -48,3 +54,54 @@ describe('libellés', () => {
|
||||||
assert.equal(titreAvecVersion('X'), 'Gestion table tournante Libre — X');
|
assert.equal(titreAvecVersion('X'), 'Gestion table tournante Libre — X');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Les modules de table de libelles/, épreuves exceptées, par ordre
|
||||||
|
// alphabétique, et ce que chacun exporte.
|
||||||
|
async function tablesDuDossier() {
|
||||||
|
const noms = readdirSync(new URL('./libelles/', import.meta.url))
|
||||||
|
.filter((nom) => nom.endsWith('.js') && !nom.endsWith('.test.js'))
|
||||||
|
.sort();
|
||||||
|
return Promise.all(noms.map(async (nom) => ({ table: nom.slice(0, -3), module: await import(`./libelles/${nom}`) })));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('libellés : les tables des écrans (§ 14.6)', () => {
|
||||||
|
test('chaque module de libelles/ exporte TABLE, une Map ; TABLES_INTERFACE les assemble, onze, par ordre alphabétique', async () => {
|
||||||
|
const tables = await tablesDuDossier();
|
||||||
|
assert.equal(tables.length, 11);
|
||||||
|
assert.deepEqual(tables.map(({ table }) => table), [
|
||||||
|
'accueil', 'communs', 'coquille', 'diagnostic', 'evenement', 'generation',
|
||||||
|
'import', 'participants', 'plan', 'propositions', 'tables',
|
||||||
|
]);
|
||||||
|
for (const { table, module } of tables) assert.ok(module.TABLE instanceof Map, table);
|
||||||
|
assert.equal(TABLES_INTERFACE.length, tables.length);
|
||||||
|
tables.forEach(({ table, module }, rang) => assert.equal(TABLES_INTERFACE[rang], module.TABLE, table));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("les tables d'écran ne portent que des libellés, chacun préfixé du nom de sa table", async () => {
|
||||||
|
const tables = await tablesDuDossier();
|
||||||
|
let comptees = 0;
|
||||||
|
for (const { table, module } of tables) {
|
||||||
|
const forme = new RegExp(`^${table}(?:\\.[a-z][A-Za-z0-9]*)+$`);
|
||||||
|
for (const cle of module.TABLE.keys()) {
|
||||||
|
comptees += 1;
|
||||||
|
assert.match(cle, forme, table);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(comptees > 0, 'aucune clé');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("les tables de l'application et des écrans se réunissent sans clé partagée", () => {
|
||||||
|
assert.ok(TABLES_APPLICATION.length > 0 && TABLES_INTERFACE.length > 0);
|
||||||
|
const { cles } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||||
|
assert.ok(cles().length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("la coquille se lit en français dans coquille.js : son titre est celui de titreAvecVersion, ses provenances celles de libelleProvenance", () => {
|
||||||
|
const { t } = creerTraduction(TABLES_INTERFACE);
|
||||||
|
assert.equal(t('coquille.titre', { version: 'X' }), titreAvecVersion('X'));
|
||||||
|
assert.equal(t('coquille.nom'), 'Gestion table tournante Libre');
|
||||||
|
for (const mode of ['developpement', 'livraison', 'documentation', 'test']) {
|
||||||
|
assert.equal(t(`coquille.provenance.${mode}`), libelleProvenance(mode), mode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
7
src/interface/libelles/accueil.js
Normal file
7
src/interface/libelles/accueil.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Les libellés de l'accueil : la liste des événements, les démonstrations, les
|
||||||
|
// opérations de fichiers, la corbeille, le dossier de travail et la
|
||||||
|
// récupération (§ 14.6). Chaque clé commence par « accueil. ».
|
||||||
|
export const TABLE = new Map();
|
||||||
6
src/interface/libelles/communs.js
Normal file
6
src/interface/libelles/communs.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Les libellés communs aux écrans : commandes, confirmations, remèdes d'un
|
||||||
|
// refus (§ 14.6). Chaque clé commence par « communs. ».
|
||||||
|
export const TABLE = new Map();
|
||||||
14
src/interface/libelles/coquille.js
Normal file
14
src/interface/libelles/coquille.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Les textes de la coquille (§ 18.6) : le nom de l'application, le titre du
|
||||||
|
// bandeau et de la fenêtre, et la provenance de la construction par mode de
|
||||||
|
// Vite. Un mode sans clé s'affiche sous son propre nom (libelles.js).
|
||||||
|
export const TABLE = new Map([
|
||||||
|
['coquille.nom', 'Gestion table tournante Libre'],
|
||||||
|
['coquille.titre', ({ version }, texte) => `${texte('coquille.nom')} \u{2014} ${version}`],
|
||||||
|
['coquille.provenance.developpement', 'développement'],
|
||||||
|
['coquille.provenance.livraison', 'livraison'],
|
||||||
|
['coquille.provenance.documentation', 'documentation'],
|
||||||
|
['coquille.provenance.test', 'épreuve'],
|
||||||
|
]);
|
||||||
6
src/interface/libelles/diagnostic.js
Normal file
6
src/interface/libelles/diagnostic.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Les libellés du diagnostic d'une configuration (§ 14.6). Chaque clé commence
|
||||||
|
// par « diagnostic. ».
|
||||||
|
export const TABLE = new Map();
|
||||||
7
src/interface/libelles/evenement.js
Normal file
7
src/interface/libelles/evenement.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Les libellés de l'événement ouvert : son en-tête, son mode, ses
|
||||||
|
// avertissements, le support rompu (§ 14.6). Chaque clé commence par «
|
||||||
|
// evenement. ».
|
||||||
|
export const TABLE = new Map();
|
||||||
6
src/interface/libelles/generation.js
Normal file
6
src/interface/libelles/generation.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Les libellés de la génération : réglages et avancement (§ 14.6). Chaque clé
|
||||||
|
// commence par « generation. ».
|
||||||
|
export const TABLE = new Map();
|
||||||
6
src/interface/libelles/import.js
Normal file
6
src/interface/libelles/import.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Les libellés de l'import d'une liste : aperçu et rapport (§ 14.6). Chaque
|
||||||
|
// clé commence par « import. ».
|
||||||
|
export const TABLE = new Map();
|
||||||
6
src/interface/libelles/participants.js
Normal file
6
src/interface/libelles/participants.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Les libellés de la liste des participants : grille, formulaire, fiche (§
|
||||||
|
// 14.6). Chaque clé commence par « participants. ».
|
||||||
|
export const TABLE = new Map();
|
||||||
6
src/interface/libelles/plan.js
Normal file
6
src/interface/libelles/plan.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Les libellés du plan de salle : vue, tables, places, panneaux et listes de
|
||||||
|
// travail (§ 14.6). Chaque clé commence par « plan. ».
|
||||||
|
export const TABLE = new Map();
|
||||||
6
src/interface/libelles/propositions.js
Normal file
6
src/interface/libelles/propositions.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Les libellés du tableau des propositions (§ 14.6). Chaque clé commence par «
|
||||||
|
// propositions. ».
|
||||||
|
export const TABLE = new Map();
|
||||||
6
src/interface/libelles/tables.js
Normal file
6
src/interface/libelles/tables.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Les libellés des tables et des tours (§ 14.6). Chaque clé commence par «
|
||||||
|
// tables. ».
|
||||||
|
export const TABLE = new Map();
|
||||||
74
src/interface/marques.js
Normal file
74
src/interface/marques.js
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Registre des marques porteuses d'information et des textes (§ 14.5,
|
||||||
|
// § 7.5, § 7.6, § 7.7). Chaque entrée nomme le jeton qui la peint et chaque
|
||||||
|
// jeton sur lequel elle se peint ; verifierPalette (palette.js) mesure le
|
||||||
|
// seuil de chaque couple dans les deux thèmes, sous les trois dichromaties,
|
||||||
|
// et les épreuves de rendu atteignent la marque par data-marque, qui porte
|
||||||
|
// son nom.
|
||||||
|
//
|
||||||
|
// Les remplissages d'une chaise sont son fond, son remplissage plein et les
|
||||||
|
// deux couleurs de la hachure — des traits --couleur-hachure sur le fond de
|
||||||
|
// la chaise : une marque posée sur une chaise se mesure sur chacun.
|
||||||
|
|
||||||
|
const c = (nom) => `--couleur-${nom}`;
|
||||||
|
|
||||||
|
const REMPLISSAGES_CHAISE = ['chaise-fond', 'chaise-pleine', 'hachure'];
|
||||||
|
|
||||||
|
function entree(nom, jeton, fonds, seuil) {
|
||||||
|
return Object.freeze({ nom, jeton: c(jeton), fonds: Object.freeze(fonds.map(c)), seuil });
|
||||||
|
}
|
||||||
|
|
||||||
|
const marque = (nom, jeton, fonds) => entree(nom, jeton, fonds, 3);
|
||||||
|
const texte = (nom, jeton, fonds) => entree(nom, jeton, fonds, 4.5);
|
||||||
|
|
||||||
|
/** Les marques porteuses d'information (§ 14.5) : { nom, jeton, fonds, seuil: 3 } — nom, la
|
||||||
|
* désignation data-marque ; jeton, celui qui la peint ; fonds, chaque jeton sur lequel elle
|
||||||
|
* se peint, chaque remplissage d'une chaise pour une marque qui passe sur une chaise. */
|
||||||
|
export const MARQUES = Object.freeze([
|
||||||
|
// Sur la chaise (§ 7.6) : cadenas posés sur ses remplissages ; l'anneau
|
||||||
|
// l'entoure, sur le sol et le bord du plateau qu'elle touche.
|
||||||
|
marque('cadenasFerme', 'marque', REMPLISSAGES_CHAISE),
|
||||||
|
marque('cadenasOuvert', 'marque', REMPLISSAGES_CHAISE),
|
||||||
|
marque('anneau', 'marque', ['sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
|
||||||
|
marque('hachure', 'hachure', ['chaise-fond']),
|
||||||
|
marque('remplissagePlein', 'chaise-pleine', ['sol', 'table-fond']),
|
||||||
|
// Le conflit (§ 7.5) : contour épaissi sur le sol, trame sur le plateau,
|
||||||
|
// pastille chiffrée près de la table.
|
||||||
|
marque('contourConflit', 'conflit', ['sol', 'table-fond']),
|
||||||
|
marque('trameConflit', 'conflit', ['table-fond']),
|
||||||
|
marque('pastille', 'pastille-fond', ['sol', 'table-fond']),
|
||||||
|
marque('poignee', 'poignee', ['sol']),
|
||||||
|
marque('gel', 'gel', ['sol', 'table-fond']),
|
||||||
|
// La désignation se pose sur la page, le sol, le plateau et une chaise
|
||||||
|
// vide, pourvue ou à pourvoir : chacun de ses remplissages est un fond.
|
||||||
|
marque('miseEnEvidence', 'mise-en-evidence', ['page-fond', 'sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
|
||||||
|
marque('selection', 'selection', ['page-fond', 'sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
|
||||||
|
// Le cadre de mode (§ 8.4) entoure le contenu de l'événement, sur la page.
|
||||||
|
marque('cadreLecture', 'cadre-lecture', ['page-fond']),
|
||||||
|
marque('cadreEcriture', 'cadre-ecriture', ['page-fond']),
|
||||||
|
marque('cadreBloque', 'cadre-bloque', ['page-fond']),
|
||||||
|
marque('focus', 'focus', ['page-fond', 'bandeau-fond', 'sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
|
||||||
|
// Le mobilier sur le sol : le trait de la table, celui de la chaise, qui
|
||||||
|
// touche aussi le plateau.
|
||||||
|
marque('contourTable', 'table-trait', ['sol']),
|
||||||
|
marque('contourChaise', 'chaise-trait', ['sol', 'table-fond']),
|
||||||
|
// L'échelle graphique (§ 7.7) vit dans la surface du plan, sur le sol ou la
|
||||||
|
// page ; le fantôme d'un glissement passe sur le sol, les tables et les
|
||||||
|
// chaises.
|
||||||
|
marque('echelle', 'echelle', ['sol', 'page-fond']),
|
||||||
|
marque('fantome', 'fantome', ['sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Les textes et leurs fonds, même forme, seuil 4,5 : page, secondaire, bandeau, refus,
|
||||||
|
* avis, listes du plan sur le sol, pastille. */
|
||||||
|
export const TEXTES = Object.freeze([
|
||||||
|
texte('page', 'page-texte', ['page-fond']),
|
||||||
|
texte('secondaire', 'page-texte-secondaire', ['page-fond']),
|
||||||
|
texte('bandeau', 'bandeau-texte', ['bandeau-fond']),
|
||||||
|
texte('refus', 'refus', ['page-fond']),
|
||||||
|
texte('avis', 'avis', ['page-fond']),
|
||||||
|
texte('listes', 'liste-texte', ['sol']),
|
||||||
|
texte('pastille', 'pastille-texte', ['pastille-fond']),
|
||||||
|
]);
|
||||||
132
src/interface/marques.test.js
Normal file
132
src/interface/marques.test.js
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Le registre des marques porteuses d'information et des textes (§ 14.5,
|
||||||
|
// § 7.5, § 7.6, § 7.7), et la palette de jetons.css validée par calcul sur
|
||||||
|
// lui (§ 12.7) : chaque entrée tient son seuil sur chacun de ses fonds, dans
|
||||||
|
// les deux thèmes, en couleurs d'origine puis sous les trois dichromaties.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
|
import { MARQUES, TEXTES } from './marques.js';
|
||||||
|
import { lireBlocs, lireJetons, verifierPalette } from './palette.js';
|
||||||
|
|
||||||
|
const JETONS = readFileSync(new URL('./jetons.css', import.meta.url), 'utf8');
|
||||||
|
const THEMES = ['clair', 'sombre'];
|
||||||
|
|
||||||
|
const NOMS_MARQUES = [
|
||||||
|
'cadenasFerme', 'cadenasOuvert', 'anneau', 'hachure', 'remplissagePlein',
|
||||||
|
'contourConflit', 'trameConflit', 'pastille', 'poignee', 'gel', 'miseEnEvidence',
|
||||||
|
'selection', 'cadreLecture', 'cadreEcriture', 'cadreBloque', 'focus',
|
||||||
|
'contourTable', 'contourChaise', 'echelle', 'fantome',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Les remplissages d'une chaise : fond, pleine, et les deux couleurs de la
|
||||||
|
// hachure — ses traits sur le fond de la chaise.
|
||||||
|
const REMPLISSAGES_CHAISE = ['--couleur-chaise-fond', '--couleur-chaise-pleine', '--couleur-hachure'];
|
||||||
|
|
||||||
|
// Fonds minimaux des marques de désignation et du fantôme. « chaise » vaut
|
||||||
|
// chacun des remplissages d'une chaise : la marque se pose sur une chaise
|
||||||
|
// vide, pourvue ou à pourvoir.
|
||||||
|
const c = (nom) => `--couleur-${nom}`;
|
||||||
|
const FONDS_REQUIS = {
|
||||||
|
focus: [...['page-fond', 'bandeau-fond', 'sol', 'table-fond'].map(c), ...REMPLISSAGES_CHAISE],
|
||||||
|
selection: [...['page-fond', 'sol', 'table-fond'].map(c), ...REMPLISSAGES_CHAISE],
|
||||||
|
miseEnEvidence: [...['page-fond', 'sol', 'table-fond'].map(c), ...REMPLISSAGES_CHAISE],
|
||||||
|
fantome: [...['sol', 'table-fond'].map(c), ...REMPLISSAGES_CHAISE],
|
||||||
|
};
|
||||||
|
|
||||||
|
const parNom = (registre, nom) => registre.find((e) => e.nom === nom);
|
||||||
|
|
||||||
|
describe('registre des marques', () => {
|
||||||
|
test('les vingt marques du contrat, noms uniques, seuil 3, au moins un fond chacune', () => {
|
||||||
|
assert.equal(MARQUES.length, NOMS_MARQUES.length);
|
||||||
|
assert.deepEqual(MARQUES.map((m) => m.nom), NOMS_MARQUES);
|
||||||
|
assert.equal(new Set(MARQUES.map((m) => m.nom)).size, MARQUES.length);
|
||||||
|
for (const marque of MARQUES) {
|
||||||
|
assert.deepEqual(Object.keys(marque).sort(), ['fonds', 'jeton', 'nom', 'seuil'], marque.nom);
|
||||||
|
assert.equal(marque.seuil, 3, marque.nom);
|
||||||
|
assert.ok(marque.fonds.length > 0, marque.nom);
|
||||||
|
assert.equal(new Set(marque.fonds).size, marque.fonds.length, `${marque.nom} : fond en double`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le registre est figé', () => {
|
||||||
|
assert.ok(Object.isFrozen(MARQUES) && Object.isFrozen(TEXTES));
|
||||||
|
assert.ok(MARQUES.every((m) => Object.isFrozen(m) && Object.isFrozen(m.fonds)));
|
||||||
|
assert.ok(TEXTES.every((m) => Object.isFrozen(m) && Object.isFrozen(m.fonds)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le focus, la sélection, la mise en évidence et le fantôme se mesurent sur chaque fond où ils se peignent, chaque remplissage de chaise compris', () => {
|
||||||
|
for (const nom of ['focus', 'selection', 'miseEnEvidence', 'fantome']) {
|
||||||
|
const marque = parNom(MARQUES, nom);
|
||||||
|
assert.notEqual(marque, undefined, nom);
|
||||||
|
for (const fond of FONDS_REQUIS[nom]) {
|
||||||
|
assert.ok(marque.fonds.includes(fond), `${nom} : ${fond}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une marque posée sur une chaise se mesure sur chacun de ses remplissages', () => {
|
||||||
|
const surChaise = ['cadenasFerme', 'cadenasOuvert', 'anneau'];
|
||||||
|
for (const nom of surChaise) {
|
||||||
|
for (const fond of REMPLISSAGES_CHAISE) {
|
||||||
|
assert.ok(parNom(MARQUES, nom).fonds.includes(fond), `${nom} : ${fond}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepEqual(parNom(MARQUES, 'hachure').fonds, ['--couleur-chaise-fond']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chaque jeton et chaque fond du registre existe dans les deux thèmes', () => {
|
||||||
|
const entrees = [...MARQUES, ...TEXTES];
|
||||||
|
assert.ok(entrees.length > 20);
|
||||||
|
for (const theme of THEMES) {
|
||||||
|
const jetons = lireJetons(JETONS, theme);
|
||||||
|
for (const entree of entrees) {
|
||||||
|
for (const nom of [entree.jeton, ...entree.fonds]) {
|
||||||
|
assert.ok(jetons.has(nom), `${theme} : ${entree.nom} → ${nom}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registre des textes', () => {
|
||||||
|
test('page, secondaire, bandeau, refus, avis, listes du plan, pastille ; seuil 4,5', () => {
|
||||||
|
assert.deepEqual(TEXTES.map((t) => t.nom), ['page', 'secondaire', 'bandeau', 'refus', 'avis', 'listes', 'pastille']);
|
||||||
|
assert.ok(TEXTES.every((t) => t.seuil === 4.5 && t.fonds.length > 0));
|
||||||
|
assert.deepEqual(parNom(TEXTES, 'listes').fonds, ['--couleur-sol']);
|
||||||
|
assert.deepEqual(parNom(TEXTES, 'pastille').fonds, ['--couleur-pastille-fond']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('palette de jetons.css validée par calcul (§ 12.7, § 14.5)', () => {
|
||||||
|
test('aucun jeton de texte ou de marque, ni aucun de leurs fonds, n’a de canal alpha', () => {
|
||||||
|
const noms = new Set([...MARQUES, ...TEXTES].flatMap((e) => [e.jeton, ...e.fonds]));
|
||||||
|
assert.ok(noms.size > 20);
|
||||||
|
const blocs = lireBlocs(JETONS);
|
||||||
|
for (const theme of THEMES) {
|
||||||
|
const selecteur = `:root[data-theme='${theme}']`;
|
||||||
|
const brutes = new Map(
|
||||||
|
blocs.filter((b) => b.selecteurs.includes(selecteur)).flatMap((b) => [...b.declarations]),
|
||||||
|
);
|
||||||
|
const jetons = lireJetons(JETONS, theme);
|
||||||
|
for (const nom of noms) {
|
||||||
|
assert.equal(jetons.get(nom)[3], 1, `${theme} : ${nom}`);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
brutes.get(nom),
|
||||||
|
/rgba|hsla|transparent|#[0-9a-f]{8}\b|#[0-9a-f]{4}\b|\/\s*[\d.]+%?\s*\)/i,
|
||||||
|
`${theme} : ${nom}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chaque marque tient 3:1 sur chacun de ses fonds, dans les deux thèmes, sous les trois simulations', () => {
|
||||||
|
assert.deepEqual(verifierPalette(JETONS, MARQUES), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chaque texte tient 4,5:1 sur chacun de ses fonds, dans les deux thèmes, sous les trois simulations', () => {
|
||||||
|
assert.deepEqual(verifierPalette(JETONS, TEXTES), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
292
src/interface/palette.js
Normal file
292
src/interface/palette.js
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// La palette se valide par calcul (§ 12.7, § 14.5). Le module lit les jetons
|
||||||
|
// de couleur dans le texte de jetons.css, simule les trois dichromaties,
|
||||||
|
// mesure le contraste au sens des WCAG et contrôle la teinte réservée au
|
||||||
|
// conflit. Il est pur : il reçoit le texte de la feuille, ne lit aucun
|
||||||
|
// fichier et ne touche pas le document.
|
||||||
|
//
|
||||||
|
// Une couleur est un tableau [r, g, b, a] : composantes sRGB de 0 à 255,
|
||||||
|
// alpha de 0 à 1.
|
||||||
|
|
||||||
|
const THEMES = ['clair', 'sombre'];
|
||||||
|
|
||||||
|
// Ordre des simulations dans les fautes rendues : il ne dépend pas de l'ordre
|
||||||
|
// des clés de SIMULATIONS.
|
||||||
|
const ORDRE_SIMULATIONS = ['protanopie', 'deuteranopie', 'tritanopie'];
|
||||||
|
|
||||||
|
// Une couleur se lit chromatique quand sa plus grande et sa plus petite
|
||||||
|
// composante s'écartent d'au moins SEUIL_CHROMATIQUE sur 255 ; en deçà, elle
|
||||||
|
// se lit grise et sa teinte n'a pas de sens. Deux teintes nommées voisines,
|
||||||
|
// le rouge et l'orange, se tiennent à ECART_TEINTE degrés.
|
||||||
|
const SEUIL_CHROMATIQUE = 26;
|
||||||
|
const ECART_TEINTE = 30;
|
||||||
|
|
||||||
|
const PREFIXE_COULEUR = '--couleur-';
|
||||||
|
|
||||||
|
// Les seuls sélecteurs sous lesquels une couleur se déclare : :root, ce qui
|
||||||
|
// s'applique sans attribut, et un bloc par thème.
|
||||||
|
const SELECTEURS_COULEUR = [':root', ...THEMES.map((t) => `:root[data-theme='${t}']`)];
|
||||||
|
|
||||||
|
/** Matrices de Machado, Oliveira et Fernandes (2009), sévérité 1, appliquées en
|
||||||
|
* RGB linéaire. Chaque ligne somme à 1 : le blanc et le noir sont des points
|
||||||
|
* fixes, et un gris reste gris. */
|
||||||
|
export const SIMULATIONS = Object.freeze({
|
||||||
|
protanopie: Object.freeze([
|
||||||
|
Object.freeze([0.152286, 1.052583, -0.204868]),
|
||||||
|
Object.freeze([0.114503, 0.786281, 0.099216]),
|
||||||
|
Object.freeze([-0.003882, -0.048116, 1.051998]),
|
||||||
|
]),
|
||||||
|
deuteranopie: Object.freeze([
|
||||||
|
Object.freeze([0.367322, 0.860646, -0.227968]),
|
||||||
|
Object.freeze([0.280085, 0.672501, 0.047413]),
|
||||||
|
Object.freeze([-0.01182, 0.04294, 0.968881]),
|
||||||
|
]),
|
||||||
|
tritanopie: Object.freeze([
|
||||||
|
Object.freeze([1.255528, -0.076749, -0.178779]),
|
||||||
|
Object.freeze([-0.078411, 0.930809, 0.147602]),
|
||||||
|
Object.freeze([0.004733, 0.691367, 0.3039]),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Les blocs de règles d'une feuille, dans leur ordre : { selecteurs, declarations },
|
||||||
|
* selecteurs la liste des sélecteurs, espaces resserrés et guillemets
|
||||||
|
* d'attribut ramenés à l'apostrophe ; declarations une Map propriété → valeur
|
||||||
|
* brute, la dernière déclaration d'une propriété l'emportant. Les commentaires
|
||||||
|
* sont ôtés. Un bloc imbriqué (@media, @supports) ou non fermé lève : la
|
||||||
|
* feuille de jetons n'en porte aucun, et un thème caché dans une règle
|
||||||
|
* conditionnelle échapperait à la lecture. */
|
||||||
|
export function lireBlocs(texteCss) {
|
||||||
|
const texte = texteCss.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||||
|
const blocs = [];
|
||||||
|
let debut = 0;
|
||||||
|
for (;;) {
|
||||||
|
const ouvrant = texte.indexOf('{', debut);
|
||||||
|
if (ouvrant === -1) {
|
||||||
|
if (texte.slice(debut).includes('}')) {
|
||||||
|
throw new Error('accolade fermante sans bloc ouvert');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const fermant = texte.indexOf('}', ouvrant);
|
||||||
|
if (fermant === -1) {
|
||||||
|
throw new Error(`bloc non fermé : ${texte.slice(debut, ouvrant).trim()}`);
|
||||||
|
}
|
||||||
|
const imbrique = texte.indexOf('{', ouvrant + 1);
|
||||||
|
if (imbrique !== -1 && imbrique < fermant) {
|
||||||
|
throw new Error(`bloc imbriqué non lu : ${texte.slice(debut, ouvrant).trim()}`);
|
||||||
|
}
|
||||||
|
const selecteurs = texte
|
||||||
|
.slice(debut, ouvrant)
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim().replace(/\s+/g, ' ').replace(/"/g, "'"));
|
||||||
|
const declarations = new Map();
|
||||||
|
for (const declaration of texte.slice(ouvrant + 1, fermant).split(';')) {
|
||||||
|
const deuxPoints = declaration.indexOf(':');
|
||||||
|
if (deuxPoints === -1) continue;
|
||||||
|
const nom = declaration.slice(0, deuxPoints).trim();
|
||||||
|
const valeur = declaration.slice(deuxPoints + 1).trim();
|
||||||
|
if (nom !== '') declarations.set(nom, valeur);
|
||||||
|
}
|
||||||
|
blocs.push({ selecteurs, declarations });
|
||||||
|
debut = fermant + 1;
|
||||||
|
}
|
||||||
|
return blocs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// [r, g, b, a] d'une valeur de couleur littérale, ou null pour une forme que le
|
||||||
|
// module ne lit pas. Formes lues : #rgb, #rgba, #rrggbb, #rrggbbaa,
|
||||||
|
// rgb()/rgba() à virgules ou à espaces, alpha en nombre ou en pourcentage,
|
||||||
|
// transparent.
|
||||||
|
function couleurLitterale(valeur) {
|
||||||
|
const v = valeur.trim().toLowerCase();
|
||||||
|
if (v === 'transparent') return [0, 0, 0, 0];
|
||||||
|
const h = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(v);
|
||||||
|
if (h !== null) {
|
||||||
|
const chiffres = h[1].length <= 4 ? [...h[1]].map((c) => c + c) : h[1].match(/../g);
|
||||||
|
const [r, g, b, a = 'ff'] = chiffres;
|
||||||
|
return [...[r, g, b].map((x) => Number.parseInt(x, 16)), a === 'ff' ? 1 : Number.parseInt(a, 16) / 255];
|
||||||
|
}
|
||||||
|
const f = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*(?:[,/]\s*([\d.]+)(%?)\s*)?\)$/.exec(v);
|
||||||
|
if (f !== null) {
|
||||||
|
const alpha = f[4] === undefined ? 1 : Number(f[4]) / (f[5] === '%' ? 100 : 1);
|
||||||
|
return [Number(f[1]), Number(f[2]), Number(f[3]), alpha];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Couleurs d'un thème lues dans le texte de jetons.css : Map nom → [r, g, b, a],
|
||||||
|
* dans l'ordre des déclarations. Un jeton de couleur est une propriété
|
||||||
|
* --couleur-… ; il vaut une couleur littérale ou var(--couleur-…) d'un autre
|
||||||
|
* jeton du même thème. theme 'clair' ou 'sombre' : les blocs dont un sélecteur
|
||||||
|
* est :root[data-theme='<theme>'] ; theme null : les blocs dont un sélecteur
|
||||||
|
* est :root seul, ce qui s'applique sans attribut. Lève sur un thème sans
|
||||||
|
* bloc, une valeur illisible, un renvoi sans cible ou circulaire, et sur un
|
||||||
|
* bloc qui déclare une couleur sous un autre sélecteur que :root et
|
||||||
|
* :root[data-theme='clair' | 'sombre'] : dans le navigateur, il masquerait
|
||||||
|
* le jeton que la lecture mesure. */
|
||||||
|
export function lireJetons(texteCss, theme) {
|
||||||
|
const tous = lireBlocs(texteCss);
|
||||||
|
for (const bloc of tous) {
|
||||||
|
const autres = bloc.selecteurs.filter((s) => !SELECTEURS_COULEUR.includes(s));
|
||||||
|
const couleur = [...bloc.declarations.keys()].find((n) => n.startsWith(PREFIXE_COULEUR));
|
||||||
|
if (autres.length > 0 && couleur !== undefined) {
|
||||||
|
throw new Error(`couleur ${couleur} déclarée sous un sélecteur non lu : ${autres.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const selecteur = theme === null ? ':root' : `:root[data-theme='${theme}']`;
|
||||||
|
const blocs = tous.filter((b) => b.selecteurs.includes(selecteur));
|
||||||
|
if (blocs.length === 0) {
|
||||||
|
throw new Error(`aucun bloc pour le thème ${theme}`);
|
||||||
|
}
|
||||||
|
const brutes = new Map();
|
||||||
|
for (const bloc of blocs) {
|
||||||
|
for (const [nom, valeur] of bloc.declarations) {
|
||||||
|
if (nom.startsWith(PREFIXE_COULEUR)) brutes.set(nom, valeur);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const resolues = new Map();
|
||||||
|
const resoudre = (nom, chemin) => {
|
||||||
|
if (resolues.has(nom)) return resolues.get(nom);
|
||||||
|
if (chemin.includes(nom)) {
|
||||||
|
throw new Error(`renvoi circulaire : ${[...chemin, nom].join(' → ')}`);
|
||||||
|
}
|
||||||
|
if (!brutes.has(nom)) {
|
||||||
|
throw new Error(`jeton absent du thème ${theme} : ${nom}${chemin.length ? ` (lu par ${chemin.at(-1)})` : ''}`);
|
||||||
|
}
|
||||||
|
const valeur = brutes.get(nom);
|
||||||
|
const renvoi = /^var\(\s*(--[\w-]+)\s*\)$/.exec(valeur);
|
||||||
|
const couleur = renvoi !== null ? resoudre(renvoi[1], [...chemin, nom]) : couleurLitterale(valeur);
|
||||||
|
if (couleur === null) {
|
||||||
|
throw new Error(`couleur illisible, thème ${theme} : ${nom}: ${valeur}`);
|
||||||
|
}
|
||||||
|
resolues.set(nom, couleur);
|
||||||
|
return couleur;
|
||||||
|
};
|
||||||
|
const jetons = new Map();
|
||||||
|
for (const nom of brutes.keys()) {
|
||||||
|
jetons.set(nom, resoudre(nom, []));
|
||||||
|
}
|
||||||
|
return jetons;
|
||||||
|
}
|
||||||
|
|
||||||
|
const versLineaire = (c) => {
|
||||||
|
const s = c / 255;
|
||||||
|
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||||||
|
};
|
||||||
|
|
||||||
|
const versSrgb = (l) => {
|
||||||
|
const borne = Math.min(1, Math.max(0, l));
|
||||||
|
const s = borne <= 0.0031308 ? borne * 12.92 : 1.055 * borne ** (1 / 2.4) - 0.055;
|
||||||
|
return s * 255;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** La couleur que voit une personne atteinte de la dichromatie nommée : la
|
||||||
|
* matrice s'applique aux composantes linéaires, le résultat est borné à
|
||||||
|
* [0, 1] puis rendu en sRGB, sans arrondi. Alpha inchangé. */
|
||||||
|
export function simuler(couleur, nomSimulation) {
|
||||||
|
if (!Object.hasOwn(SIMULATIONS, nomSimulation)) {
|
||||||
|
throw new Error(`simulation inconnue : ${nomSimulation}`);
|
||||||
|
}
|
||||||
|
const matrice = SIMULATIONS[nomSimulation];
|
||||||
|
const lineaire = couleur.slice(0, 3).map(versLineaire);
|
||||||
|
const simulee = matrice.map((ligne) => versSrgb(ligne[0] * lineaire[0] + ligne[1] * lineaire[1] + ligne[2] * lineaire[2]));
|
||||||
|
return [...simulee, couleur[3] ?? 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function luminance(couleur) {
|
||||||
|
const [r, g, b] = couleur.map(versLineaire);
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rapport de contraste des WCAG entre deux couleurs opaques, de 1 à 21,
|
||||||
|
* symétrique. La seule implémentation du dépôt (contrainte 1) :
|
||||||
|
* App.navigateur.test.js l'importe, test/navigateur/rendu.js la réexporte.
|
||||||
|
* Une couleur dont l'alpha est donné et différent de 1 lève : le ratio d'une
|
||||||
|
* couleur composée avec ce qui est dessous n'est pas celui de ses
|
||||||
|
* composantes. */
|
||||||
|
export function contraste(a, b) {
|
||||||
|
for (const couleur of [a, b]) {
|
||||||
|
if (couleur[3] !== undefined && couleur[3] !== 1) {
|
||||||
|
throw new Error(`contraste d'une couleur à canal alpha ${couleur[3]} : [${couleur.join(', ')}]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const la = luminance(a.slice(0, 3));
|
||||||
|
const lb = luminance(b.slice(0, 3));
|
||||||
|
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chaque entrée tient son seuil sur chacun de ses fonds, sur les couleurs
|
||||||
|
* d'origine puis sous chaque simulation, dans les deux thèmes. entrees :
|
||||||
|
* [{jeton, fonds, seuil}], noms de jetons --couleur-…. Rend les fautes
|
||||||
|
* [{theme, simulation, jeton, fond, ratio, seuil}], simulation valant
|
||||||
|
* 'origine' pour les couleurs d'origine, dans l'ordre thème, simulation,
|
||||||
|
* entrée, fond ; [] quand tout tient. Un jeton absent d'un thème lève. */
|
||||||
|
export function verifierPalette(texteCss, entrees) {
|
||||||
|
const fautes = [];
|
||||||
|
for (const theme of THEMES) {
|
||||||
|
const jetons = lireJetons(texteCss, theme);
|
||||||
|
const lire = (nom) => {
|
||||||
|
if (!jetons.has(nom)) throw new Error(`jeton absent du thème ${theme} : ${nom}`);
|
||||||
|
return jetons.get(nom);
|
||||||
|
};
|
||||||
|
for (const simulation of ['origine', ...ORDRE_SIMULATIONS]) {
|
||||||
|
const vue = (c) => (simulation === 'origine' ? c : simuler(c, simulation));
|
||||||
|
for (const { jeton, fonds, seuil } of entrees) {
|
||||||
|
const couleur = vue(lire(jeton));
|
||||||
|
for (const fond of fonds) {
|
||||||
|
const ratio = contraste(couleur, vue(lire(fond)));
|
||||||
|
if (ratio < seuil) fautes.push({ theme, simulation, jeton, fond, ratio, seuil });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fautes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Teinte HSL d'une couleur en degrés, dans [0, 360), ou null quand sa plus
|
||||||
|
* grande et sa plus petite composante s'écartent de moins de
|
||||||
|
* SEUIL_CHROMATIQUE : la couleur se lit grise. */
|
||||||
|
export function teinte(couleur) {
|
||||||
|
const [r, g, b] = couleur;
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
const etendue = max - min;
|
||||||
|
if (etendue < SEUIL_CHROMATIQUE) return null;
|
||||||
|
let h;
|
||||||
|
if (max === r) h = ((g - b) / etendue) % 6;
|
||||||
|
else if (max === g) h = (b - r) / etendue + 2;
|
||||||
|
else h = (r - g) / etendue + 4;
|
||||||
|
const degres = h * 60;
|
||||||
|
return degres < 0 ? degres + 360 : degres;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** § 12.7 : la teinte de chaque jeton chromatique se tient à ECART_TEINTE
|
||||||
|
* degrés au moins de celle du conflit, dans chaque thème. jetonsDuConflit :
|
||||||
|
* le jeton de conflit d'abord, puis ceux qui ne peignent que les marques de
|
||||||
|
* conflit, que le contrôle ne mesure pas. Rend les fautes [{theme, jeton,
|
||||||
|
* ecart}], ecart en degrés sur le cercle ; un jeton de conflit achromatique
|
||||||
|
* est lui-même une faute, d'écart null — aucune teinte n'est alors réservée. */
|
||||||
|
export function verifierTeinteReservee(texteCss, jetonsDuConflit) {
|
||||||
|
const [conflit] = jetonsDuConflit;
|
||||||
|
const fautes = [];
|
||||||
|
for (const theme of THEMES) {
|
||||||
|
const jetons = lireJetons(texteCss, theme);
|
||||||
|
if (!jetons.has(conflit)) throw new Error(`jeton absent du thème ${theme} : ${conflit}`);
|
||||||
|
const reference = teinte(jetons.get(conflit));
|
||||||
|
if (reference === null) {
|
||||||
|
fautes.push({ theme, jeton: conflit, ecart: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const [nom, couleur] of jetons) {
|
||||||
|
if (jetonsDuConflit.includes(nom)) continue;
|
||||||
|
const h = teinte(couleur);
|
||||||
|
if (h === null) continue;
|
||||||
|
const brut = Math.abs(h - reference);
|
||||||
|
const ecart = Math.min(brut, 360 - brut);
|
||||||
|
if (ecart < ECART_TEINTE) fautes.push({ theme, jeton: nom, ecart });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fautes;
|
||||||
|
}
|
||||||
356
src/interface/palette.test.js
Normal file
356
src/interface/palette.test.js
Normal file
|
|
@ -0,0 +1,356 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Lecture des jetons, simulation des dichromaties, contraste et teinte
|
||||||
|
// réservée (§ 12.7, § 13.3, § 14.5). Les fonctions s'éprouvent d'abord sur des
|
||||||
|
// feuilles d'épreuve écrites ici, où chaque défaut est posé exprès, puis sur
|
||||||
|
// le vrai fichier de jetons : ses familles, ses deux thèmes, les jetons que
|
||||||
|
// lisent les composants, la teinte du conflit.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
|
import {
|
||||||
|
SIMULATIONS,
|
||||||
|
contraste,
|
||||||
|
lireBlocs,
|
||||||
|
lireJetons,
|
||||||
|
simuler,
|
||||||
|
teinte,
|
||||||
|
verifierPalette,
|
||||||
|
verifierTeinteReservee,
|
||||||
|
} from './palette.js';
|
||||||
|
|
||||||
|
const DOSSIER = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
const JETONS = readFileSync(new URL('./jetons.css', import.meta.url), 'utf8');
|
||||||
|
const NOMS_SIMULATIONS = ['protanopie', 'deuteranopie', 'tritanopie'];
|
||||||
|
|
||||||
|
// Feuille d'épreuve : le même corps de couleurs dans les deux thèmes, le
|
||||||
|
// clair valant aussi sans attribut.
|
||||||
|
function feuille(couleurs, sombre = couleurs) {
|
||||||
|
const corps = (table) =>
|
||||||
|
Object.entries(table)
|
||||||
|
.map(([nom, valeur]) => ` ${nom}: ${valeur};`)
|
||||||
|
.join('\n');
|
||||||
|
return [
|
||||||
|
':root { --espace-1: 0.25rem; }',
|
||||||
|
`:root,\n:root[data-theme='clair'] {\n color-scheme: light;\n${corps(couleurs)}\n}`,
|
||||||
|
`:root[data-theme="sombre"] {\n color-scheme: dark;\n${corps(sombre)}\n}`,
|
||||||
|
].join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Composantes sRGB de 0 à 255 d'une couleur #rrggbb.
|
||||||
|
function hex(valeur) {
|
||||||
|
return [1, 3, 5].map((i) => Number.parseInt(valeur.slice(i, i + 2), 16));
|
||||||
|
}
|
||||||
|
|
||||||
|
const enHex = (c) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`;
|
||||||
|
|
||||||
|
describe('lireBlocs et lireJetons', () => {
|
||||||
|
test('lit les blocs dans leur ordre, commentaires ôtés, et les couleurs de chaque thème', () => {
|
||||||
|
const css = [
|
||||||
|
"/* { un commentaire n'ouvre aucun bloc } */",
|
||||||
|
':root { --espace-1: 0.25rem; --police-interface: system-ui, sans-serif; }',
|
||||||
|
":root, :root[data-theme='clair'] {",
|
||||||
|
' --couleur-a: #fff;',
|
||||||
|
' --couleur-b: #102030;',
|
||||||
|
' --couleur-c: rgb(1, 2, 3);',
|
||||||
|
' --couleur-d: rgb(4 5 6);',
|
||||||
|
' --couleur-e: var(--couleur-b);',
|
||||||
|
' --couleur-f: rgba(7, 8, 9, 0.5);',
|
||||||
|
' --couleur-g: #10203080;',
|
||||||
|
' --couleur-h: transparent;',
|
||||||
|
'}',
|
||||||
|
':root[data-theme="sombre"] { --couleur-a: #000000; }',
|
||||||
|
].join('\n');
|
||||||
|
const blocs = lireBlocs(css);
|
||||||
|
assert.equal(blocs.length, 3);
|
||||||
|
assert.deepEqual(blocs[0].selecteurs, [':root']);
|
||||||
|
assert.deepEqual(blocs[1].selecteurs, [':root', ":root[data-theme='clair']"]);
|
||||||
|
assert.deepEqual(blocs[2].selecteurs, [":root[data-theme='sombre']"]);
|
||||||
|
assert.equal(blocs[0].declarations.get('--police-interface'), 'system-ui, sans-serif');
|
||||||
|
|
||||||
|
const clair = lireJetons(css, 'clair');
|
||||||
|
assert.deepEqual([...clair.keys()], [
|
||||||
|
'--couleur-a', '--couleur-b', '--couleur-c', '--couleur-d',
|
||||||
|
'--couleur-e', '--couleur-f', '--couleur-g', '--couleur-h',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(clair.get('--couleur-a'), [255, 255, 255, 1]);
|
||||||
|
assert.deepEqual(clair.get('--couleur-b'), [16, 32, 48, 1]);
|
||||||
|
assert.deepEqual(clair.get('--couleur-c'), [1, 2, 3, 1]);
|
||||||
|
assert.deepEqual(clair.get('--couleur-d'), [4, 5, 6, 1]);
|
||||||
|
assert.deepEqual(clair.get('--couleur-e'), [16, 32, 48, 1]);
|
||||||
|
assert.deepEqual(clair.get('--couleur-f'), [7, 8, 9, 0.5]);
|
||||||
|
assert.deepEqual(clair.get('--couleur-g'), [16, 32, 48, 128 / 255]);
|
||||||
|
assert.deepEqual(clair.get('--couleur-h'), [0, 0, 0, 0]);
|
||||||
|
assert.deepEqual([...lireJetons(css, 'sombre')], [['--couleur-a', [0, 0, 0, 1]]]);
|
||||||
|
// Sans attribut : les seuls blocs :root, ceux que le clair partage.
|
||||||
|
assert.deepEqual(lireJetons(css, null), clair);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lève sur un thème absent, une couleur illisible, un renvoi sans cible et un bloc imbriqué', () => {
|
||||||
|
const css = feuille({ '--couleur-a': '#ffffff' });
|
||||||
|
assert.throws(() => lireJetons(css, 'contraste'), /contraste/);
|
||||||
|
assert.throws(() => lireJetons(feuille({ '--couleur-a': 'hsl(0 0% 50%)' }), 'clair'), /--couleur-a/);
|
||||||
|
assert.throws(() => lireJetons(feuille({ '--couleur-a': 'var(--couleur-z)' }), 'clair'), /--couleur-z/);
|
||||||
|
assert.throws(
|
||||||
|
() => lireJetons(feuille({ '--couleur-a': 'var(--couleur-b)', '--couleur-b': 'var(--couleur-a)' }), 'clair'),
|
||||||
|
/--couleur-a/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => lireBlocs('@media (prefers-color-scheme: dark) { :root { --couleur-a: #000; } }'),
|
||||||
|
/imbriqué/,
|
||||||
|
);
|
||||||
|
assert.throws(() => lireBlocs(':root { --couleur-a: #000;'), /fermé/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lève sur une couleur déclarée sous un autre sélecteur que :root et les deux thèmes : elle masquerait le jeton lu', () => {
|
||||||
|
const base = feuille({ '--couleur-sol': '#eeeeee', '--couleur-focus': '#5a3fc0' });
|
||||||
|
for (const intrus of [
|
||||||
|
":root[data-theme='sombre'] svg { --couleur-sol: #ff8a80; --couleur-focus: #e8e8e8; }",
|
||||||
|
'html { --couleur-sol: #ff8a80; }',
|
||||||
|
":root[data-theme='contraste'] { --couleur-sol: #ff8a80; }",
|
||||||
|
":root, .plan { --couleur-sol: #ff8a80; }",
|
||||||
|
]) {
|
||||||
|
const css = `${base}\n${intrus}`;
|
||||||
|
for (const theme of ['clair', 'sombre', null]) {
|
||||||
|
assert.throws(() => lireJetons(css, theme), /sélecteur/, `${theme} : ${intrus}`);
|
||||||
|
}
|
||||||
|
assert.throws(() => lireJetons(`${JETONS}\n${intrus}`, 'sombre'), /sélecteur/, intrus);
|
||||||
|
}
|
||||||
|
// Un autre sélecteur sans couleur n'est pas lu, et ne lève pas.
|
||||||
|
assert.deepEqual(lireJetons(`${base}\n.plan { --espace-1: 1rem; }`, 'clair'), lireJetons(base, 'clair'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('contraste et simulations', () => {
|
||||||
|
test('noir sur blanc vaut 21, une couleur sur elle-même 1, dans les deux sens', () => {
|
||||||
|
const noir = [0, 0, 0, 1];
|
||||||
|
const blanc = [255, 255, 255, 1];
|
||||||
|
assert.ok(Math.abs(contraste(noir, blanc) - 21) < 1e-9);
|
||||||
|
assert.equal(contraste(blanc, noir), contraste(noir, blanc));
|
||||||
|
assert.equal(contraste([90, 120, 30, 1], [90, 120, 30, 1]), 1);
|
||||||
|
// Valeur connue : #767676 sur blanc, 4,54:1 au sens des WCAG.
|
||||||
|
assert.ok(Math.abs(contraste(hex('#767676'), hex('#ffffff')) - 4.54) < 0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lève sur une couleur à canal alpha partiel : le ratio d'une couleur composée mentirait", () => {
|
||||||
|
assert.throws(() => contraste([0, 0, 0, 0.5], [255, 255, 255, 1]), /alpha/);
|
||||||
|
assert.throws(() => contraste([0, 0, 0, 1], [255, 255, 255, 0]), /alpha/);
|
||||||
|
assert.ok(contraste([0, 0, 0], [255, 255, 255]) > 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('les trois matrices sont celles de Machado, Oliveira et Fernandes, sévérité 1', () => {
|
||||||
|
assert.deepEqual(Object.keys(SIMULATIONS).sort(), [...NOMS_SIMULATIONS].sort());
|
||||||
|
assert.deepEqual(SIMULATIONS.protanopie[0], [0.152286, 1.052583, -0.204868]);
|
||||||
|
assert.deepEqual(SIMULATIONS.deuteranopie[1], [0.280085, 0.672501, 0.047413]);
|
||||||
|
assert.deepEqual(SIMULATIONS.tritanopie[2], [0.004733, 0.691367, 0.3039]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le blanc et le noir sont des points fixes des trois matrices, à 1/255 près ; alpha intact', () => {
|
||||||
|
assert.equal(NOMS_SIMULATIONS.length, 3);
|
||||||
|
for (const nom of NOMS_SIMULATIONS) {
|
||||||
|
for (const couleur of [[255, 255, 255, 1], [0, 0, 0, 1]]) {
|
||||||
|
const simulee = simuler(couleur, nom);
|
||||||
|
for (let i = 0; i < 3; i += 1) {
|
||||||
|
assert.ok(Math.abs(simulee[i] - couleur[i]) <= 1, `${nom} ${couleur} → ${simulee}`);
|
||||||
|
}
|
||||||
|
assert.equal(simulee[3], 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.throws(() => simuler([0, 0, 0, 1], 'achromatopsie'), /achromatopsie/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un gris reste gris, un rouge pur perd sa clarté sous la protanopie', () => {
|
||||||
|
const gris = simuler([118, 118, 118, 1], 'deuteranopie');
|
||||||
|
assert.ok(Math.abs(gris[0] - 118) <= 1 && Math.abs(gris[2] - 118) <= 1, `${gris}`);
|
||||||
|
const rouge = [255, 0, 0, 1];
|
||||||
|
assert.ok(contraste(simuler(rouge, 'protanopie'), [0, 0, 0, 1]) < contraste(rouge, [0, 0, 0, 1]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifierPalette', () => {
|
||||||
|
// Cherche un rouge et un vert qui tiennent 3:1 en couleurs d'origine et
|
||||||
|
// tombent sous 3:1 sous au moins une simulation.
|
||||||
|
function coupleRougeVert() {
|
||||||
|
for (let a = 0; a <= 200; a += 8) {
|
||||||
|
for (let v = 40; v <= 200; v += 8) {
|
||||||
|
const rouge = [255, a, a, 1];
|
||||||
|
const vert = [0, v, 0, 1];
|
||||||
|
if (contraste(rouge, vert) < 3) continue;
|
||||||
|
const simule = NOMS_SIMULATIONS.some(
|
||||||
|
(nom) => contraste(simuler(rouge, nom), simuler(vert, nom)) < 3,
|
||||||
|
);
|
||||||
|
if (simule) return { rouge, vert };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('un couple rouge-vert qui tient 3:1 en origine et tombe une fois simulé est refusé, sous simulation seulement', () => {
|
||||||
|
const couple = coupleRougeVert();
|
||||||
|
assert.notEqual(couple, null);
|
||||||
|
const css = feuille({ '--couleur-rouge': enHex(couple.rouge.slice(0, 3)), '--couleur-vert': enHex(couple.vert.slice(0, 3)) });
|
||||||
|
const fautes = verifierPalette(css, [{ jeton: '--couleur-rouge', fonds: ['--couleur-vert'], seuil: 3 }]);
|
||||||
|
assert.ok(fautes.length > 0);
|
||||||
|
assert.ok(fautes.every((f) => f.simulation !== 'origine'), JSON.stringify(fautes));
|
||||||
|
assert.ok(fautes.every((f) => f.ratio < 3 && f.seuil === 3));
|
||||||
|
assert.ok(fautes.every((f) => f.jeton === '--couleur-rouge' && f.fond === '--couleur-vert'));
|
||||||
|
assert.deepEqual([...new Set(fautes.map((f) => f.theme))], ['clair', 'sombre']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une marque qui tient sur son premier fond et pas sur le second est refusée sur le second', () => {
|
||||||
|
const css = feuille({
|
||||||
|
'--couleur-marque': '#202020',
|
||||||
|
'--couleur-clair': '#ffffff',
|
||||||
|
'--couleur-sombre': '#000000',
|
||||||
|
});
|
||||||
|
const fautes = verifierPalette(css, [
|
||||||
|
{ jeton: '--couleur-marque', fonds: ['--couleur-clair', '--couleur-sombre'], seuil: 3 },
|
||||||
|
]);
|
||||||
|
// Deux thèmes, l'origine et trois simulations : huit fautes, toutes sur le second fond.
|
||||||
|
assert.equal(fautes.length, 8);
|
||||||
|
assert.ok(fautes.every((f) => f.fond === '--couleur-sombre'));
|
||||||
|
assert.deepEqual(
|
||||||
|
fautes.map((f) => `${f.theme}:${f.simulation}`),
|
||||||
|
['clair', 'sombre'].flatMap((t) => ['origine', ...NOMS_SIMULATIONS].map((s) => `${t}:${s}`)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rend [] quand chaque entrée tient sur chaque fond ; lève sur un jeton inconnu', () => {
|
||||||
|
const css = feuille({ '--couleur-texte': '#000000', '--couleur-fond': '#ffffff' }, { '--couleur-texte': '#ffffff', '--couleur-fond': '#000000' });
|
||||||
|
assert.deepEqual(verifierPalette(css, [{ jeton: '--couleur-texte', fonds: ['--couleur-fond'], seuil: 4.5 }]), []);
|
||||||
|
assert.throws(
|
||||||
|
() => verifierPalette(css, [{ jeton: '--couleur-texte', fonds: ['--couleur-absente'], seuil: 3 }]),
|
||||||
|
/--couleur-absente/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('teinte et verifierTeinteReservee (§ 12.7)', () => {
|
||||||
|
test('la teinte HSL ne vaut que pour une couleur dont les composantes s’écartent de 26 au moins', () => {
|
||||||
|
assert.equal(teinte([255, 0, 0, 1]), 0);
|
||||||
|
assert.equal(teinte([0, 255, 0, 1]), 120);
|
||||||
|
assert.equal(teinte([0, 0, 255, 1]), 240);
|
||||||
|
assert.equal(teinte([255, 0, 128, 1]) > 329 && teinte([255, 0, 128, 1]) < 331, true);
|
||||||
|
assert.equal(teinte([138, 125, 125, 1]), null); // écart 13 : gris
|
||||||
|
assert.equal(teinte([125, 100, 100, 1]), null); // écart 25 : gris
|
||||||
|
assert.notEqual(teinte([126, 100, 100, 1]), null); // écart 26 : chromatique
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un refus de la teinte du conflit et d’une autre clarté est refusé, valeurs pourtant distinctes', () => {
|
||||||
|
const conflit = '#c62828';
|
||||||
|
const refus = '#5a1212';
|
||||||
|
assert.notEqual(conflit, refus);
|
||||||
|
assert.ok(Math.abs(teinte(hex(conflit)) - teinte(hex(refus))) < 1);
|
||||||
|
const css = feuille({
|
||||||
|
'--couleur-conflit': conflit,
|
||||||
|
'--couleur-conflit-trame': '#e57373',
|
||||||
|
'--couleur-refus': refus,
|
||||||
|
'--couleur-gris-rose': '#8a7d7d',
|
||||||
|
'--couleur-bleu': '#1f3a5f',
|
||||||
|
});
|
||||||
|
const fautes = verifierTeinteReservee(css, ['--couleur-conflit', '--couleur-conflit-trame']);
|
||||||
|
assert.deepEqual(fautes.map((f) => `${f.theme}:${f.jeton}`), ['clair:--couleur-refus', 'sombre:--couleur-refus']);
|
||||||
|
assert.ok(fautes.every((f) => f.ecart < 30));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le jeton de conflit lui-même doit être chromatique ; une teinte à 30° passe, à 29° non ; le cercle se referme', () => {
|
||||||
|
const gris = feuille({ '--couleur-conflit': '#808080', '--couleur-bleu': '#1f3a5f' });
|
||||||
|
assert.deepEqual(verifierTeinteReservee(gris, ['--couleur-conflit']), [
|
||||||
|
{ theme: 'clair', jeton: '--couleur-conflit', ecart: null },
|
||||||
|
{ theme: 'sombre', jeton: '--couleur-conflit', ecart: null },
|
||||||
|
]);
|
||||||
|
// Conflit à 0° ; 30° et 330° passent, 29° et 331° non.
|
||||||
|
const css = feuille({
|
||||||
|
'--couleur-conflit': '#ff0000',
|
||||||
|
'--couleur-a': '#ff8000', // 30,1°
|
||||||
|
'--couleur-b': '#ff7b00', // 28,9°
|
||||||
|
'--couleur-c': '#ff0080', // 329,9°
|
||||||
|
'--couleur-d': '#ff0079', // 331,5°
|
||||||
|
});
|
||||||
|
const fautes = verifierTeinteReservee(css, ['--couleur-conflit']);
|
||||||
|
assert.deepEqual(fautes.map((f) => `${f.theme}:${f.jeton}`), [
|
||||||
|
'clair:--couleur-b', 'clair:--couleur-d', 'sombre:--couleur-b', 'sombre:--couleur-d',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fichiers .svelte et .css de src/interface, sous-dossiers compris, triés.
|
||||||
|
function sourcesDeStyle() {
|
||||||
|
return readdirSync(DOSSIER, { recursive: true, withFileTypes: true })
|
||||||
|
.filter((e) => e.isFile() && /\.(svelte|css)$/.test(e.name))
|
||||||
|
.map((e) => join(e.parentPath, e.name))
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('jetons.css', () => {
|
||||||
|
// Lue dans chaque épreuve : une feuille illisible fait échouer chacune, sans
|
||||||
|
// empêcher les épreuves des fonctions de se lancer.
|
||||||
|
const blocs = () => lireBlocs(JETONS);
|
||||||
|
const declares = () => new Set(blocs().flatMap((b) => [...b.declarations.keys()]));
|
||||||
|
const nomsDe = (prefixe) => [...declares()].filter((n) => n.startsWith(prefixe));
|
||||||
|
|
||||||
|
test('un fichier, toutes les familles : couleurs, polices, échelle typographique, espacements, rayons, ombres (§ 13.3)', () => {
|
||||||
|
for (const prefixe of ['--couleur-', '--police-', '--taille-', '--espace-', '--rayon-', '--ombre-']) {
|
||||||
|
assert.ok(nomsDe(prefixe).length > 0, `aucun jeton ${prefixe}…`);
|
||||||
|
}
|
||||||
|
// Une échelle a trois pas au moins, de valeurs distinctes.
|
||||||
|
const tailles = new Set(
|
||||||
|
blocs().flatMap((b) => [...b.declarations].filter(([n]) => n.startsWith('--taille-')).map(([, v]) => v)),
|
||||||
|
);
|
||||||
|
assert.ok(tailles.size >= 3, `${tailles.size} tailles distinctes`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deux thèmes posés par data-theme, de mêmes jetons de couleur ; sans attribut, le clair ; aucune préférence du système lue', () => {
|
||||||
|
const clair = lireJetons(JETONS, 'clair');
|
||||||
|
const sombre = lireJetons(JETONS, 'sombre');
|
||||||
|
assert.ok(clair.size >= 20, `${clair.size} couleurs`);
|
||||||
|
assert.deepEqual([...sombre.keys()].sort(), [...clair.keys()].sort());
|
||||||
|
assert.deepEqual(lireJetons(JETONS, null), clair);
|
||||||
|
// Le thème sombre redéfinit chaque couleur à une autre valeur que le clair,
|
||||||
|
// sauf celles que les deux thèmes partagent exprès.
|
||||||
|
const differentes = [...clair.keys()].filter((n) => String(clair.get(n)) !== String(sombre.get(n)));
|
||||||
|
assert.ok(differentes.length > clair.size / 2, `${differentes.length} couleurs changent`);
|
||||||
|
assert.equal(/prefers-color-scheme/.test(JETONS.replace(/\/\*[\s\S]*?\*\//g, '')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chaque var(--…) lu par un .svelte ou un .css de src/interface est déclaré dans jetons.css', () => {
|
||||||
|
const sources = sourcesDeStyle();
|
||||||
|
assert.ok(sources.length >= 2, `${sources.length} sources de style`);
|
||||||
|
const lus = [];
|
||||||
|
for (const chemin of sources) {
|
||||||
|
for (const [, nom] of readFileSync(chemin, 'utf8').matchAll(/var\(\s*(--[\w-]+)/g)) {
|
||||||
|
lus.push({ chemin, nom });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(lus.length >= 11, `${lus.length} lectures`);
|
||||||
|
const noms = declares();
|
||||||
|
const absents = lus.filter((l) => !noms.has(l.nom)).map((l) => `${l.chemin} ${l.nom}`);
|
||||||
|
assert.deepEqual(absents, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("les onze jetons d'App.svelte gardent leur nom", () => {
|
||||||
|
const noms = declares();
|
||||||
|
for (const nom of [
|
||||||
|
'--couleur-page-fond', '--couleur-page-texte', '--couleur-bandeau-fond',
|
||||||
|
'--couleur-bandeau-texte', '--couleur-bandeau-filet', '--police-interface',
|
||||||
|
'--taille-texte-bandeau', '--espace-2', '--espace-3', '--espace-4', '--rayon-1',
|
||||||
|
]) {
|
||||||
|
assert.ok(noms.has(nom), nom);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la teinte du conflit est réservée (§ 12.7) : chromatique, sa valeur à lui seul, à 30° de tout autre jeton chromatique', () => {
|
||||||
|
for (const theme of ['clair', 'sombre']) {
|
||||||
|
const jetons = lireJetons(JETONS, theme);
|
||||||
|
const conflit = jetons.get('--couleur-conflit');
|
||||||
|
assert.notEqual(conflit, undefined, theme);
|
||||||
|
assert.notEqual(teinte(conflit), null, `${theme} : conflit achromatique`);
|
||||||
|
const memeValeur = [...jetons].filter(([n, c]) => n !== '--couleur-conflit' && String(c) === String(conflit));
|
||||||
|
assert.deepEqual(memeValeur.map(([n]) => n), [], theme);
|
||||||
|
}
|
||||||
|
assert.deepEqual(verifierTeinteReservee(JETONS, ['--couleur-conflit']), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
66
src/moteur/conflits.js
Normal file
66
src/moteur/conflits.js
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Conflits par table (§ 7.5) : les violations des règles actives, table par
|
||||||
|
// table et tour par tour, que le plan lit sans rien recalculer. Elles se
|
||||||
|
// lisent dans parcourirTours (indicateurs.js), le passage dont mesurer tire
|
||||||
|
// ses chiffres : la somme des paires séparer d'un plan égale ses collisions
|
||||||
|
// cumulées, celle des personnes nouvelle table ses retours choisis, et les
|
||||||
|
// paires distinctes nouveaux voisins ses rencontres répétées choisies
|
||||||
|
// (§ 13.2). La pastille d'une table compte des occurrences de violation —
|
||||||
|
// une paire en collision, une paire revue, une personne revenue —, et chaque
|
||||||
|
// règle porte les siennes dans son unité (§ 5.4).
|
||||||
|
//
|
||||||
|
// « Varier les appartenances » ne se localise pas à une table : aucune
|
||||||
|
// violation n'en sort.
|
||||||
|
import { parcourirTours } from './indicateurs.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ConflitsTable
|
||||||
|
* @property {number} table identifiant
|
||||||
|
* @property {number} nombre violations des règles actives à ce tour, toutes règles
|
||||||
|
* @property {Array<{regle: 'separerAppartenances'|'nouveauxVoisins', paires: number[][]}
|
||||||
|
* |{regle: 'nouvelleTable', personnes: number[]}>} regles
|
||||||
|
* identifiants croissants, règles dans cet ordre ; une règle inactive
|
||||||
|
* ou sans violation est omise
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Violations des règles actives (§ 7.5), lues dans parcourirTours et rendues
|
||||||
|
* en identifiants : séparer les appartenances — les collègues réunis ;
|
||||||
|
* nouveaux voisins — les paires revues ; nouvelle table — les personnes
|
||||||
|
* revenues. Une paire revue se signale à chaque rencontre répétée, sauf quand
|
||||||
|
* toutes ses rencontres jusque-là ont eu lieu à une table où les deux étaient
|
||||||
|
* réservés ; une personne revenue, à chaque visite répétée d'une table, sauf
|
||||||
|
* quand elle y est réservée à ce tour et l'a été à une visite antérieure.
|
||||||
|
*
|
||||||
|
* Les index de personnes croissent avec les identifiants : les listes de
|
||||||
|
* parcourirTours, traduites terme à terme, restent croissantes.
|
||||||
|
*
|
||||||
|
* Ne modifie ni l'instance ni le plan ; lève ce que lève exigerPlanIndexe.
|
||||||
|
*
|
||||||
|
* @param {import('./types.js').Instance} instance
|
||||||
|
* @param {ArrayLike<number>} tableDe
|
||||||
|
* @returns {ConflitsTable[][]} [tour][index de table], tables dans l'ordre de l'instance
|
||||||
|
*/
|
||||||
|
export function conflitsParTable(instance, tableDe) {
|
||||||
|
const { ids, idsTables, contraintes } = instance;
|
||||||
|
const enIds = (paires) => paires.map(([a, b]) => [ids[a], ids[b]]);
|
||||||
|
return parcourirTours(instance, tableDe).parTour.map((tables) =>
|
||||||
|
tables.map(({ collegues, revues, revenus }, t) => {
|
||||||
|
const regles = [];
|
||||||
|
if (contraintes.separerAppartenances && collegues.length > 0) {
|
||||||
|
regles.push({ regle: 'separerAppartenances', paires: enIds(collegues) });
|
||||||
|
}
|
||||||
|
if (contraintes.nouveauxVoisins && revues.length > 0) {
|
||||||
|
regles.push({ regle: 'nouveauxVoisins', paires: enIds(revues) });
|
||||||
|
}
|
||||||
|
if (contraintes.nouvelleTable && revenus.length > 0) {
|
||||||
|
regles.push({ regle: 'nouvelleTable', personnes: revenus.map((p) => ids[p]) });
|
||||||
|
}
|
||||||
|
let nombre = 0;
|
||||||
|
for (const regle of regles) nombre += (regle.paires ?? regle.personnes).length;
|
||||||
|
return { table: idsTables[t], nombre, regles };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
227
src/moteur/conflits.long.test.js
Normal file
227
src/moteur/conflits.long.test.js
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Épreuves lourdes des conflits par table (§ 7.5, § 13.2) : les violations
|
||||||
|
// par table, sommées sur un plan entier, rendent les chiffres de la mesure.
|
||||||
|
// Σ des paires séparer = collisionsCumulees ; Σ des personnes nouvelle table
|
||||||
|
// = totalRetoursChoisis ; paires distinctes nouveaux voisins =
|
||||||
|
// rencontresRepetees.choisies. D'abord les propositions d'une génération sur
|
||||||
|
// la grande démonstration, puis cent plans tirés sur de petites
|
||||||
|
// configurations, chacun de sa propre graine.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
|
import { CATALOGUE } from '../demo/catalogue.js';
|
||||||
|
import { FLUX, creerPcg32 } from '../demo/prng.js';
|
||||||
|
import { conflitsParTable } from './conflits.js';
|
||||||
|
import { LIBRE, indexerPlan, normaliser, planDepuisIndex } from './configuration.js';
|
||||||
|
import { mesurer } from './indicateurs.js';
|
||||||
|
import { rechercher } from './recherche.js';
|
||||||
|
import { verifierInvariants } from './verification.js';
|
||||||
|
|
||||||
|
// Une génération courte : la qualité des propositions n'importe pas ici, et
|
||||||
|
// des propositions qui portent encore des conflits éprouvent mieux l'accord.
|
||||||
|
const GENERATION = Object.freeze({ graine: 7, arret: 20_000, nombre: 2 });
|
||||||
|
|
||||||
|
// Les cent plans tirés : la graine du plan i est GRAINE + i.
|
||||||
|
const PLANS = 100;
|
||||||
|
const GRAINE = 2_718;
|
||||||
|
const PLANS_ACCEPTES_MIN = 90;
|
||||||
|
|
||||||
|
// Planchers des comptes de la boucle, posés au-dessous de ce que donnent les
|
||||||
|
// graines écrites ci-dessus : 100 plans acceptés ; 69 dont les trois totaux
|
||||||
|
// sont non nuls ; 32 où une paire réunie librement se retrouve à une table où
|
||||||
|
// les deux sont réservés ; 41 où une personne assise librement à une table y
|
||||||
|
// revient réservée.
|
||||||
|
const TOUS_NON_NULS_MIN = 55;
|
||||||
|
const PAIRE_PUIS_RESERVEE_MIN = 25;
|
||||||
|
const PERSONNE_PUIS_RESERVEE_MIN = 32;
|
||||||
|
|
||||||
|
const LIBELLES = Object.freeze(['Grisaille', 'Club des Merles', 'Ondée']);
|
||||||
|
|
||||||
|
// Les trois sommes d'un plan, lues dans conflitsParTable : paires séparer,
|
||||||
|
// personnes nouvelle table, et paires distinctes nouveaux voisins, clé
|
||||||
|
// « a,b » en identifiants.
|
||||||
|
function sommer(conflits) {
|
||||||
|
let separer = 0;
|
||||||
|
let nouvelleTable = 0;
|
||||||
|
const revues = new Set();
|
||||||
|
for (const tables of conflits) {
|
||||||
|
for (const { regles } of tables) {
|
||||||
|
for (const { regle, paires, personnes } of regles) {
|
||||||
|
if (regle === 'separerAppartenances') separer += paires.length;
|
||||||
|
else if (regle === 'nouvelleTable') nouvelleTable += personnes.length;
|
||||||
|
else for (const [a, b] of paires) revues.add(`${a},${b}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { separer, nouvelleTable, nouveauxVoisins: revues.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les écarts entre les sommes par table et la mesure du même plan.
|
||||||
|
function ecarts(nom, instance, tableDe) {
|
||||||
|
const sommes = sommer(conflitsParTable(instance, tableDe));
|
||||||
|
const mesures = mesurer(instance, tableDe);
|
||||||
|
const attendu = {
|
||||||
|
separer: mesures.collisionsCumulees,
|
||||||
|
nouvelleTable: mesures.totalRetoursChoisis,
|
||||||
|
nouveauxVoisins: mesures.rencontresRepetees.choisies,
|
||||||
|
};
|
||||||
|
const fautes = [];
|
||||||
|
for (const cle of ['separer', 'nouvelleTable', 'nouveauxVoisins']) {
|
||||||
|
if (sommes[cle] !== attendu[cle]) {
|
||||||
|
fautes.push(`${nom} : ${cle} ${sommes[cle]} par table, ${attendu[cle]} mesurés`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { fautes, sommes };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Une petite configuration tirée de rng : de 2 à 6 tables de 2 à 5 sièges,
|
||||||
|
// de 1 à 5 tours, de la moitié des sièges à tous occupés ; une appartenance
|
||||||
|
// parmi trois, ou aucune, pour chacun. Un participant sur cinq est ancré, un
|
||||||
|
// sur trois réservé à une partie des tours quand R ≥ 2, chacun à une table
|
||||||
|
// où il reste un siège à chacun de ses tours : normaliser l'accepte toujours.
|
||||||
|
// Séparer, nouveaux voisins et nouvelle table sont actifs.
|
||||||
|
function tirerConfiguration(rng) {
|
||||||
|
const T = 2 + rng.borne(5);
|
||||||
|
const capacites = Array.from({ length: T }, () => 2 + rng.borne(4));
|
||||||
|
const R = 1 + rng.borne(5);
|
||||||
|
const S = capacites.reduce((somme, c) => somme + c, 0);
|
||||||
|
const N = S - rng.borne(Math.floor(S / 2) + 1);
|
||||||
|
const fixees = new Int32Array(T * R);
|
||||||
|
const siegeLibre = (t, r) => fixees[t * R + r] < capacites[t];
|
||||||
|
const participants = [];
|
||||||
|
const reservations = [];
|
||||||
|
for (let id = 1; id <= N; id += 1) {
|
||||||
|
const g = rng.borne(LIBELLES.length + 1);
|
||||||
|
participants.push({ id, nom: `P${id}`, appartenance: g < LIBELLES.length ? LIBELLES[g] : null });
|
||||||
|
const role = rng.borne(15);
|
||||||
|
if (role < 3) {
|
||||||
|
const t = rng.borne(T);
|
||||||
|
if (Array.from({ length: R }, (_, r) => r).every((r) => siegeLibre(t, r))) {
|
||||||
|
for (let r = 0; r < R; r += 1) fixees[t * R + r] += 1;
|
||||||
|
reservations.push({ participant: id, table: t + 1, portee: 'tous' });
|
||||||
|
}
|
||||||
|
} else if (role < 8 && R >= 2) {
|
||||||
|
for (let r = 0; r < R; r += 1) {
|
||||||
|
if (rng.borne(2) === 0) continue;
|
||||||
|
const t = rng.borne(T);
|
||||||
|
if (!siegeLibre(t, r)) continue;
|
||||||
|
fixees[t * R + r] += 1;
|
||||||
|
reservations.push({ participant: id, table: t + 1, portee: 'tour', tour: r + 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
participants,
|
||||||
|
tables: capacites.map((capacite, t) => ({ id: t + 1, numero: t + 1, capacite })),
|
||||||
|
tours: R,
|
||||||
|
reservations,
|
||||||
|
contraintes: {
|
||||||
|
separerAppartenances: true,
|
||||||
|
nouveauxVoisins: true,
|
||||||
|
nouvelleTable: true,
|
||||||
|
varierAppartenances: rng.borne(2) === 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un plan qui assied chacun à chaque tour : les réservés à leur table, puis
|
||||||
|
// chaque autre, par index croissants, à une table tirée parmi celles qui lui
|
||||||
|
// laissent un siège.
|
||||||
|
function tirerPlan(rng, instance) {
|
||||||
|
const { N, T, R, capacite, fixe } = instance;
|
||||||
|
const tableDe = new Int32Array(N * R);
|
||||||
|
for (let r = 0; r < R; r += 1) {
|
||||||
|
const occupation = new Int32Array(T);
|
||||||
|
for (let p = 0; p < N; p += 1) {
|
||||||
|
const t = fixe[p * R + r];
|
||||||
|
if (t === LIBRE) continue;
|
||||||
|
tableDe[p * R + r] = t;
|
||||||
|
occupation[t] += 1;
|
||||||
|
}
|
||||||
|
for (let p = 0; p < N; p += 1) {
|
||||||
|
if (fixe[p * R + r] !== LIBRE) continue;
|
||||||
|
const ouvertes = [];
|
||||||
|
for (let t = 0; t < T; t += 1) if (occupation[t] < capacite[t]) ouvertes.push(t);
|
||||||
|
const t = ouvertes[rng.borne(ouvertes.length)];
|
||||||
|
tableDe[p * R + r] = t;
|
||||||
|
occupation[t] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tableDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vrai quand une paire réunie à une table où les deux ne sont pas réservés
|
||||||
|
// se retrouve, à un tour suivant, à une table où les deux le sont.
|
||||||
|
function pairePuisReservee({ N, R, fixe }, tableDe) {
|
||||||
|
for (let a = 0; a < N; a += 1) {
|
||||||
|
for (let b = a + 1; b < N; b += 1) {
|
||||||
|
let libre = false;
|
||||||
|
for (let r = 0; r < R; r += 1) {
|
||||||
|
const t = tableDe[a * R + r];
|
||||||
|
if (t !== tableDe[b * R + r]) continue;
|
||||||
|
const reservee = fixe[a * R + r] === t && fixe[b * R + r] === t;
|
||||||
|
if (reservee && libre) return true;
|
||||||
|
if (!reservee) libre = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vrai quand une personne assise sans réservation à une table y revient, à
|
||||||
|
// un tour suivant, réservée.
|
||||||
|
function personnePuisReservee({ N, T, R, fixe }, tableDe) {
|
||||||
|
for (let p = 0; p < N; p += 1) {
|
||||||
|
const libreA = new Uint8Array(T);
|
||||||
|
for (let r = 0; r < R; r += 1) {
|
||||||
|
const t = tableDe[p * R + r];
|
||||||
|
if (fixe[p * R + r] !== t) libreA[t] = 1;
|
||||||
|
else if (libreA[t] === 1) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('conflits par table : leurs sommes rendent la mesure (§ 7.5, § 13.2)', () => {
|
||||||
|
test('grande démonstration, deux propositions de 20 000 mouvements', () => {
|
||||||
|
const configuration = CATALOGUE.find(({ cle }) => cle === 'grande').construire();
|
||||||
|
const instance = normaliser(configuration);
|
||||||
|
const propositions = rechercher(configuration, GENERATION);
|
||||||
|
assert.equal(propositions.length, GENERATION.nombre);
|
||||||
|
const fautes = [];
|
||||||
|
for (const { id, plan } of propositions) {
|
||||||
|
assert.deepEqual(verifierInvariants(instance, plan), [], `proposition ${id}`);
|
||||||
|
fautes.push(...ecarts(`proposition ${id}`, instance, indexerPlan(instance, plan)).fautes);
|
||||||
|
}
|
||||||
|
assert.deepEqual(fautes, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cent plans tirés sur de petites configurations, ancrés et partiellement fixés', () => {
|
||||||
|
const fautes = [];
|
||||||
|
const comptes = { acceptes: 0, tousNonNuls: 0, pairePuisReservee: 0, personnePuisReservee: 0 };
|
||||||
|
for (let i = 1; i <= PLANS; i += 1) {
|
||||||
|
const rng = creerPcg32(GRAINE + i, FLUX.DEMO);
|
||||||
|
const instance = normaliser(tirerConfiguration(rng));
|
||||||
|
const tableDe = tirerPlan(rng, instance);
|
||||||
|
const violations = verifierInvariants(instance, planDepuisIndex(instance, tableDe));
|
||||||
|
if (violations.length > 0) {
|
||||||
|
fautes.push(`graine ${GRAINE + i} : plan refusé, ${JSON.stringify(violations)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
comptes.acceptes += 1;
|
||||||
|
const { fautes: fautesPlan, sommes } = ecarts(`graine ${GRAINE + i}`, instance, tableDe);
|
||||||
|
fautes.push(...fautesPlan);
|
||||||
|
if (sommes.separer > 0 && sommes.nouvelleTable > 0 && sommes.nouveauxVoisins > 0) {
|
||||||
|
comptes.tousNonNuls += 1;
|
||||||
|
}
|
||||||
|
if (pairePuisReservee(instance, tableDe)) comptes.pairePuisReservee += 1;
|
||||||
|
if (personnePuisReservee(instance, tableDe)) comptes.personnePuisReservee += 1;
|
||||||
|
}
|
||||||
|
assert.deepEqual(fautes, []);
|
||||||
|
assert.ok(comptes.acceptes >= PLANS_ACCEPTES_MIN, JSON.stringify(comptes));
|
||||||
|
assert.ok(comptes.tousNonNuls >= TOUS_NON_NULS_MIN, JSON.stringify(comptes));
|
||||||
|
assert.ok(comptes.pairePuisReservee >= PAIRE_PUIS_RESERVEE_MIN, JSON.stringify(comptes));
|
||||||
|
assert.ok(comptes.personnePuisReservee >= PERSONNE_PUIS_RESERVEE_MIN, JSON.stringify(comptes));
|
||||||
|
});
|
||||||
|
});
|
||||||
297
src/moteur/conflits.test.js
Normal file
297
src/moteur/conflits.test.js
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Épreuves des conflits par table (§ 7.5, § 5.4). Chaque plan s'écrit par
|
||||||
|
// identifiants, une liste par table, et passe par normaliser et indexerPlan
|
||||||
|
// comme un plan enregistré ; chaque valeur attendue se compte à la main dans
|
||||||
|
// le commentaire qui la précède. Les mesures citées viennent de mesurer :
|
||||||
|
// conflitsParTable et elle lisent le même parcours, et leurs chiffres
|
||||||
|
// s'accordent par construction.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
|
import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js';
|
||||||
|
import { conflitsParTable } from './conflits.js';
|
||||||
|
import { indexerPlan, normaliser } from './configuration.js';
|
||||||
|
import { mesurer } from './indicateurs.js';
|
||||||
|
|
||||||
|
const TOUTES = Object.freeze({
|
||||||
|
separerAppartenances: true,
|
||||||
|
nouveauxVoisins: true,
|
||||||
|
nouvelleTable: true,
|
||||||
|
varierAppartenances: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const MERLES = 'Club des Merles';
|
||||||
|
|
||||||
|
const tous = (participant, table) => ({ participant, table, portee: 'tous' });
|
||||||
|
const auTour = (participant, table, tour) => ({ participant, table, portee: 'tour', tour });
|
||||||
|
|
||||||
|
// Une table sans violation.
|
||||||
|
const sansConflit = (table) => ({ table, nombre: 0, regles: [] });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instance et plan indexé d'un plan écrit par identifiants. ids[i] porte
|
||||||
|
* appartenance[i] (par défaut, l'identifiant i + 1) ; idsTables[i] est
|
||||||
|
* l'identifiant de la table d'index i (par défaut i + 1), de la capacité
|
||||||
|
* donnée ; tours[r][i] liste les participants assis à la table d'index i au
|
||||||
|
* tour r + 1.
|
||||||
|
*/
|
||||||
|
function preparer({
|
||||||
|
appartenance,
|
||||||
|
ids = appartenance.map((_, i) => i + 1),
|
||||||
|
capacite,
|
||||||
|
idsTables,
|
||||||
|
tours,
|
||||||
|
reservations = [],
|
||||||
|
contraintes = TOUTES,
|
||||||
|
}) {
|
||||||
|
const participants = appartenance.map((libelle, i) => ({
|
||||||
|
id: ids[i],
|
||||||
|
nom: `P${ids[i]}`,
|
||||||
|
appartenance: libelle,
|
||||||
|
}));
|
||||||
|
const tables = (idsTables ?? tours[0].map((_, i) => i + 1)).map((id, i) => ({
|
||||||
|
id,
|
||||||
|
numero: i + 1,
|
||||||
|
capacite,
|
||||||
|
}));
|
||||||
|
const instance = normaliser({
|
||||||
|
participants,
|
||||||
|
tables,
|
||||||
|
tours: tours.length,
|
||||||
|
reservations,
|
||||||
|
contraintes: { ...contraintes },
|
||||||
|
});
|
||||||
|
const plan = { tables: tables.map(({ id }) => id), tours, reserves: tours.map(() => []) };
|
||||||
|
return { instance, tableDe: indexerPlan(instance, plan) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function conflits(description) {
|
||||||
|
const { instance, tableDe } = preparer(description);
|
||||||
|
return conflitsParTable(instance, tableDe);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mesures(description) {
|
||||||
|
const { instance, tableDe } = preparer(description);
|
||||||
|
return mesurer(instance, tableDe);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Six personnes, 1 et 2 du Club des Merles, trois tables de 2, deux tours.
|
||||||
|
const SIX = [MERLES, MERLES, null, null, null, null];
|
||||||
|
const TOUR_1 = [[3, 4], [1, 2], [5, 6]];
|
||||||
|
|
||||||
|
describe('conflitsParTable', () => {
|
||||||
|
test('séparer : la table 2 réunit 1 et 2 au tour 1, une violation ; aucune autre table, à aucun tour', () => {
|
||||||
|
// Au tour 2, (2, 6), (3, 5) et (1, 4) se rencontrent pour la première
|
||||||
|
// fois, et chacun change de table.
|
||||||
|
const description = { appartenance: SIX, capacite: 2, tours: [TOUR_1, [[2, 6], [3, 5], [1, 4]]] };
|
||||||
|
assert.deepEqual(conflits(description), [
|
||||||
|
[
|
||||||
|
sansConflit(1),
|
||||||
|
{ table: 2, nombre: 1, regles: [{ regle: 'separerAppartenances', paires: [[1, 2]] }] },
|
||||||
|
sansConflit(3),
|
||||||
|
],
|
||||||
|
[sansConflit(1), sansConflit(2), sansConflit(3)],
|
||||||
|
]);
|
||||||
|
const sansSeparer = conflits({
|
||||||
|
...description,
|
||||||
|
contraintes: { ...TOUTES, separerAppartenances: false },
|
||||||
|
});
|
||||||
|
assert.deepEqual(sansSeparer, [
|
||||||
|
[sansConflit(1), sansConflit(2), sansConflit(3)],
|
||||||
|
[sansConflit(1), sansConflit(2), sansConflit(3)],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nouvelle table : 3 revient à la table 1, 2 à la table 2 ; une visite réservée deux fois ne se signale pas', () => {
|
||||||
|
// Tour 2 : [3, 6], [2, 5], [1, 4]. 3 était à la table 1 au tour 1, 2 à
|
||||||
|
// la table 2 ; 6, 5, 1 et 4 découvrent la leur.
|
||||||
|
const description = { appartenance: SIX, capacite: 2, tours: [TOUR_1, [[3, 6], [2, 5], [1, 4]]] };
|
||||||
|
const auTour2 = (resultat) => resultat[1];
|
||||||
|
assert.deepEqual(auTour2(conflits(description)), [
|
||||||
|
{ table: 1, nombre: 1, regles: [{ regle: 'nouvelleTable', personnes: [3] }] },
|
||||||
|
{ table: 2, nombre: 1, regles: [{ regle: 'nouvelleTable', personnes: [2] }] },
|
||||||
|
sansConflit(3),
|
||||||
|
]);
|
||||||
|
// 3 ancré à la table 1 : réservé au tour 2 et à sa visite du tour 1, son
|
||||||
|
// retour est imposé.
|
||||||
|
const ancre = { ...description, reservations: [tous(3, 1)] };
|
||||||
|
assert.deepEqual(auTour2(conflits(ancre)), [
|
||||||
|
sansConflit(1),
|
||||||
|
{ table: 2, nombre: 1, regles: [{ regle: 'nouvelleTable', personnes: [2] }] },
|
||||||
|
sansConflit(3),
|
||||||
|
]);
|
||||||
|
assert.deepEqual(mesures(ancre).retoursImposes, [0, 0, 1, 0, 0, 0]);
|
||||||
|
// 3 réservé à la table 1 au seul tour 2 : sa visite du tour 1 était
|
||||||
|
// libre, le retour est choisi, et la mesure le compte choisi.
|
||||||
|
const tour2 = { ...description, reservations: [auTour(3, 1, 2)] };
|
||||||
|
assert.deepEqual(auTour2(conflits(tour2))[0], {
|
||||||
|
table: 1,
|
||||||
|
nombre: 1,
|
||||||
|
regles: [{ regle: 'nouvelleTable', personnes: [3] }],
|
||||||
|
});
|
||||||
|
assert.deepEqual(mesures(tour2).retoursChoisis, [0, 1, 1, 0, 0, 0]);
|
||||||
|
assert.deepEqual(mesures(tour2).retoursImposes, [0, 0, 0, 0, 0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nouveaux voisins : une paire réunie librement puis réservée ensemble reste signalée ; réservée aux deux rencontres, elle ne l’est plus', () => {
|
||||||
|
// Quatre personnes sans appartenance, deux tables de 2. Tour 1 : [1, 2],
|
||||||
|
// [3, 4] ; tour 2 : [3, 4], [1, 2]. Personne ne revient à sa table.
|
||||||
|
const description = {
|
||||||
|
appartenance: [null, null, null, null],
|
||||||
|
capacite: 2,
|
||||||
|
tours: [
|
||||||
|
[[1, 2], [3, 4]],
|
||||||
|
[[3, 4], [1, 2]],
|
||||||
|
],
|
||||||
|
reservations: [auTour(3, 1, 2), auTour(4, 1, 2)],
|
||||||
|
};
|
||||||
|
assert.deepEqual(conflits(description)[1], [
|
||||||
|
{ table: 1, nombre: 1, regles: [{ regle: 'nouveauxVoisins', paires: [[3, 4]] }] },
|
||||||
|
{ table: 2, nombre: 1, regles: [{ regle: 'nouveauxVoisins', paires: [[1, 2]] }] },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(mesures(description).rencontresRepetees, { choisies: 2, imposees: 0 });
|
||||||
|
// 3 et 4 réservés aussi à la table 2 au tour 1 : chacune de leurs
|
||||||
|
// rencontres est réservée pour les deux.
|
||||||
|
const imposee = {
|
||||||
|
...description,
|
||||||
|
reservations: [...description.reservations, auTour(3, 2, 1), auTour(4, 2, 1)],
|
||||||
|
};
|
||||||
|
assert.deepEqual(conflits(imposee)[1], [
|
||||||
|
sansConflit(1),
|
||||||
|
{ table: 2, nombre: 1, regles: [{ regle: 'nouveauxVoisins', paires: [[1, 2]] }] },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(mesures(imposee).rencontresRepetees, { choisies: 1, imposees: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('les trois règles à une même table, dans leur ordre, comptées ensemble, en identifiants', () => {
|
||||||
|
// Participants 5, 9, 14 et 20 ; 5 et 9 de Grisaille. Tables d'identifiants
|
||||||
|
// 40 puis 10, dans cet ordre de configuration. Les deux mêmes paires aux
|
||||||
|
// deux tours : au tour 2, la table 40 porte (5, 9) collègues et revus, 5
|
||||||
|
// et 9 revenus, quatre violations ; la table 10, (14, 20) revus, 14 et
|
||||||
|
// 20 revenus, trois.
|
||||||
|
const description = {
|
||||||
|
appartenance: ['Grisaille', 'Grisaille', null, null],
|
||||||
|
ids: [5, 9, 14, 20],
|
||||||
|
idsTables: [40, 10],
|
||||||
|
capacite: 2,
|
||||||
|
tours: [
|
||||||
|
[[5, 9], [14, 20]],
|
||||||
|
[[5, 9], [14, 20]],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert.deepEqual(conflits(description), [
|
||||||
|
[
|
||||||
|
{ table: 40, nombre: 1, regles: [{ regle: 'separerAppartenances', paires: [[5, 9]] }] },
|
||||||
|
sansConflit(10),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
table: 40,
|
||||||
|
nombre: 4,
|
||||||
|
regles: [
|
||||||
|
{ regle: 'separerAppartenances', paires: [[5, 9]] },
|
||||||
|
{ regle: 'nouveauxVoisins', paires: [[5, 9]] },
|
||||||
|
{ regle: 'nouvelleTable', personnes: [5, 9] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
table: 10,
|
||||||
|
nombre: 3,
|
||||||
|
regles: [
|
||||||
|
{ regle: 'nouveauxVoisins', paires: [[14, 20]] },
|
||||||
|
{ regle: 'nouvelleTable', personnes: [14, 20] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
// Une règle inactive est omise, les autres gardent leur ordre.
|
||||||
|
const sansVoisins = conflits({ ...description, contraintes: { ...TOUTES, nouveauxVoisins: false } });
|
||||||
|
assert.deepEqual(sansVoisins[1][0], {
|
||||||
|
table: 40,
|
||||||
|
nombre: 3,
|
||||||
|
regles: [
|
||||||
|
{ regle: 'separerAppartenances', paires: [[5, 9]] },
|
||||||
|
{ regle: 'nouvelleTable', personnes: [5, 9] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const sansTable = conflits({ ...description, contraintes: { ...TOUTES, nouvelleTable: false } });
|
||||||
|
assert.deepEqual(sansTable[1][1], {
|
||||||
|
table: 10,
|
||||||
|
nombre: 1,
|
||||||
|
regles: [{ regle: 'nouveauxVoisins', paires: [[14, 20]] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une table de plusieurs collègues : chaque paire, identifiants croissants, paires dans l’ordre lexicographique', () => {
|
||||||
|
// 2, 4, 6 et 8 du Club des Merles, assis tous les quatre à une table de 5
|
||||||
|
// avec 3, donné avant eux dans la liste : C(4, 2) = 6 paires.
|
||||||
|
const resultat = conflits({
|
||||||
|
appartenance: [null, MERLES, null, MERLES, MERLES, MERLES],
|
||||||
|
ids: [1, 2, 3, 4, 6, 8],
|
||||||
|
capacite: 5,
|
||||||
|
tours: [[[8, 6, 3, 4, 2], [1]]],
|
||||||
|
});
|
||||||
|
assert.deepEqual(resultat[0][0], {
|
||||||
|
table: 1,
|
||||||
|
nombre: 6,
|
||||||
|
regles: [
|
||||||
|
{
|
||||||
|
regle: 'separerAppartenances',
|
||||||
|
paires: [[2, 4], [2, 6], [2, 8], [4, 6], [4, 8], [6, 8]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une seule table, un seul tour : aucune violation, des répétitions à zéro, sans lever (§ 15.4)', () => {
|
||||||
|
// Trois personnes, 3 seule du Club des Merles. 1 et 2 rencontrent un
|
||||||
|
// affilié d'une appartenance : A = 1, d = 1 ; 3 n'en rencontre aucun :
|
||||||
|
// A = 0, et il sort de l'effectif de la diversité.
|
||||||
|
const description = {
|
||||||
|
appartenance: [null, null, MERLES],
|
||||||
|
capacite: 3,
|
||||||
|
tours: [[[1, 2, 3]]],
|
||||||
|
};
|
||||||
|
assert.deepEqual(conflits(description), [[sansConflit(1)]]);
|
||||||
|
const { rencontresRepetees, aggAppartenancesVues, aggDiversite } = mesures(description);
|
||||||
|
assert.deepEqual(rencontresRepetees, { choisies: 0, imposees: 0 });
|
||||||
|
assert.deepEqual(aggAppartenancesVues.tous, { min: 0, moyenne: 2 / 3, effectif: 3 });
|
||||||
|
assert.deepEqual(aggDiversite.tous, { min: 1, moyenne: 1, effectif: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aucun présent : une table, deux tours, chacun à zéro, sans lever', () => {
|
||||||
|
const instance = normaliser({
|
||||||
|
participants: [{ id: 1, nom: 'P1', appartenance: MERLES, exclu: true }],
|
||||||
|
tables: [{ id: 1, numero: 1, capacite: 2 }],
|
||||||
|
tours: 2,
|
||||||
|
reservations: [],
|
||||||
|
contraintes: { ...TOUTES },
|
||||||
|
});
|
||||||
|
assert.deepEqual(conflitsParTable(instance, new Int32Array(0)), [[sansConflit(1)], [sansConflit(1)]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plan parfait de la petite démonstration : aucune violation, à chaque table de chaque tour', () => {
|
||||||
|
const configuration = CATALOGUE.find(({ cle }) => cle === 'petite').construire();
|
||||||
|
const instance = normaliser({ ...configuration, contraintes: { ...TOUTES } });
|
||||||
|
const resultat = conflitsParTable(instance, indexerPlan(instance, PLAN_PARFAIT_PETITE));
|
||||||
|
const cases = resultat.flat();
|
||||||
|
assert.equal(cases.length, 16);
|
||||||
|
for (const [k, { nombre, regles }] of cases.entries()) {
|
||||||
|
assert.equal(nombre, 0, `case ${k}`);
|
||||||
|
assert.deepEqual(regles, [], `case ${k}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lève ce que lève exigerPlanIndexe, et ne modifie ni l'instance ni le plan", () => {
|
||||||
|
const { instance, tableDe } = preparer({
|
||||||
|
appartenance: SIX,
|
||||||
|
capacite: 2,
|
||||||
|
tours: [TOUR_1, [[3, 6], [2, 5], [1, 4]]],
|
||||||
|
});
|
||||||
|
assert.throws(() => conflitsParTable(instance, tableDe.subarray(1)), RangeError);
|
||||||
|
const avant = { tableDe: tableDe.slice(), fixe: instance.fixe.slice(), groupe: instance.groupe.slice() };
|
||||||
|
conflitsParTable(instance, tableDe);
|
||||||
|
assert.deepEqual({ tableDe, fixe: instance.fixe, groupe: instance.groupe }, avant);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -14,6 +14,11 @@
|
||||||
// configuration.js, plafond a priori de plafond.js, retours imposés de la
|
// configuration.js, plafond a priori de plafond.js, retours imposés de la
|
||||||
// mesure d'indicateurs.js. Le diagnostic ne modifie pas la configuration et
|
// mesure d'indicateurs.js. Le diagnostic ne modifie pas la configuration et
|
||||||
// lève ce que normaliser lève.
|
// lève ce que normaliser lève.
|
||||||
|
//
|
||||||
|
// Un remède se chiffre en sollicitant la même arithmétique sur une
|
||||||
|
// configuration modifiée — plus de tables, moins de tours —, jamais par une
|
||||||
|
// forme close de plus (§ 13.2) : la sonde passe par normaliser, puis par la
|
||||||
|
// fonction qui chiffre la quantité sur la configuration elle-même.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} PlancherCollisions
|
* @typedef {Object} PlancherCollisions
|
||||||
|
|
@ -23,21 +28,39 @@
|
||||||
* @property {number} plancherPairesDistinctes
|
* @property {number} plancherPairesDistinctes
|
||||||
* @property {number|null} plancherExcedent null quand il n'est pas
|
* @property {number|null} plancherExcedent null quand il n'est pas
|
||||||
* strictement positif
|
* strictement positif
|
||||||
|
* @property {number|null} tablesManquantes plus petit nombre de tables, de
|
||||||
|
* la plus grande capacité présente, dont l'ajout ramène ce plancher
|
||||||
|
* à 0 ; null quand l'effectif du groupe n'y suffit pas : les
|
||||||
|
* réservations assoient alors deux membres à une même table d'un tour
|
||||||
*
|
*
|
||||||
* @typedef {Object} Diagnostic
|
* @typedef {Object} Diagnostic
|
||||||
* @property {number} placesManquantes max(0, N − Σ c_t), par tour
|
* @property {number} placesManquantes max(0, N − Σ c_t), par tour
|
||||||
* @property {{min: number|null, max: number|null, parPersonne: number[]}} plafondAPriori
|
* @property {{min: number|null, max: number|null, parPersonne: number[],
|
||||||
|
* repartition: Array<{valeur: number, ancres: number, mobiles: number}>}} plafondAPriori
|
||||||
* parPersonne dans l'ordre de instance.ids ; min et max null pour
|
* parPersonne dans l'ordre de instance.ids ; min et max null pour
|
||||||
* une population vide
|
* une population vide ; repartition, une ligne par valeur
|
||||||
|
* présente, valeurs croissantes, les partiellement fixés parmi les
|
||||||
|
* mobiles
|
||||||
* @property {PlancherCollisions[]} collisions groupes dont le plancher de
|
* @property {PlancherCollisions[]} collisions groupes dont le plancher de
|
||||||
* collisions cumulées est > 0, dans l'ordre de instance.groupes
|
* collisions cumulées est > 0, dans l'ordre de instance.groupes
|
||||||
* @property {number|null} redondanceMinimale null sans affilié
|
* @property {number|null} redondanceMinimale null sans affilié
|
||||||
|
* @property {{toursSansRedondance: number|null}} remedes
|
||||||
|
* toursSansRedondance : plus grand R′ < R où redondanceMinimale vaut
|
||||||
|
* 0 ; null quand elle vaut déjà 0 ou null, ou qu'aucun R′ ne l'annule
|
||||||
* @property {boolean} animateursMemeAppartenance
|
* @property {boolean} animateursMemeAppartenance
|
||||||
* @property {number} redondanceImposeeParAnimateurs min(R, T) − 1 ou 0
|
* @property {number} redondanceImposeeParAnimateurs min(R, T) − 1 ou 0
|
||||||
* @property {Array<{table: number, ancres: number, capacite: number, gelee: boolean}>} ancrage
|
* @property {boolean} uneSeuleAppartenance séparation active, une seule
|
||||||
|
* appartenance, portée par chaque présent (§ 15.4)
|
||||||
|
* @property {boolean} collisionPartout tout plan qui assied chacun
|
||||||
|
* porte une collision à chaque table de chaque tour (§ 7.5)
|
||||||
|
* @property {Array<{table: number, ancres: number, capacite: number, gelee: boolean,
|
||||||
|
* moitie: boolean}>} ancrage
|
||||||
* chaque table, par identifiant, dans l'ordre de la configuration ;
|
* chaque table, par identifiant, dans l'ordre de la configuration ;
|
||||||
* gelee quand ses ancrés occupent tous ses sièges
|
* gelee quand ses ancrés occupent tous ses sièges ; moitie quand ils
|
||||||
|
* en occupent la moitié au moins, sans la geler
|
||||||
* @property {number} retoursImposes
|
* @property {number} retoursImposes
|
||||||
|
* @property {number} ancragesImposantRetours personnes dont les réservations
|
||||||
|
* imposent un retour
|
||||||
* @property {{plancher: number, mobilesAuMoins: number}|null} ecartItineraire
|
* @property {{plancher: number, mobilesAuMoins: number}|null} ecartItineraire
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
|
|
@ -154,14 +177,110 @@ function animateursMemeAppartenance({ N, statut, groupe }) {
|
||||||
|
|
||||||
// Ancrés et capacité de chaque table, dans l'ordre de l'instance, qui est
|
// Ancrés et capacité de chaque table, dans l'ordre de l'instance, qui est
|
||||||
// celui de la configuration ; gelee quand les ancrés occupent tous les sièges
|
// celui de la configuration ; gelee quand les ancrés occupent tous les sièges
|
||||||
// (§ 5.9).
|
// (§ 5.9) ; moitie quand ils en occupent la moitié au moins, 2 × a_t ≥ c_t,
|
||||||
|
// sur une table qui tourne encore.
|
||||||
function ancrage({ T, idsTables, capacite, ancresParTable }) {
|
function ancrage({ T, idsTables, capacite, ancresParTable }) {
|
||||||
return Array.from({ length: T }, (_, t) => ({
|
return Array.from({ length: T }, (_, t) => {
|
||||||
table: idsTables[t],
|
const gelee = ancresParTable[t] === capacite[t];
|
||||||
ancres: ancresParTable[t],
|
return {
|
||||||
capacite: capacite[t],
|
table: idsTables[t],
|
||||||
gelee: ancresParTable[t] === capacite[t],
|
ancres: ancresParTable[t],
|
||||||
}));
|
capacite: capacite[t],
|
||||||
|
gelee,
|
||||||
|
moitie: !gelee && 2 * ancresParTable[t] >= capacite[t],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plafonds a priori regroupés par valeur, valeurs croissantes : à chacune,
|
||||||
|
// les ancrés et les mobiles, partiellement fixés compris (§ 5.4), qui la
|
||||||
|
// portent. Les personnes se parcourent par plafond, puis par index.
|
||||||
|
function repartition(aPriori, statut) {
|
||||||
|
const ordre = Array.from({ length: aPriori.length }, (_, p) => p);
|
||||||
|
ordre.sort((p, q) => aPriori[p] - aPriori[q] || p - q);
|
||||||
|
const lignes = [];
|
||||||
|
for (const p of ordre) {
|
||||||
|
let ligne = lignes[lignes.length - 1];
|
||||||
|
if (ligne === undefined || ligne.valeur !== aPriori[p]) {
|
||||||
|
ligne = { valeur: aPriori[p], ancres: 0, mobiles: 0 };
|
||||||
|
lignes.push(ligne);
|
||||||
|
}
|
||||||
|
if (statut[p] === STATUT.ANCRE) ligne.ancres += 1;
|
||||||
|
else ligne.mobiles += 1;
|
||||||
|
}
|
||||||
|
return lignes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chaque plancher de collisions, complété de tablesManquantes. La sonde à m
|
||||||
|
// tables ajoute m tables de la plus grande capacité présente, d'identifiants
|
||||||
|
// au-delà du plus grand, et rechiffre les planchers par planchersCollisions :
|
||||||
|
// le premier m où le groupe n'y figure plus est son remède. Un groupe de s
|
||||||
|
// membres se sépare dès que s tables de plus s'offrent à ses membres libres,
|
||||||
|
// sauf quand des réservations en assoient deux à une même table d'un tour :
|
||||||
|
// au-delà de s, il garde null. Les capacités, relâchées dans le plancher, ne
|
||||||
|
// changent pas le compte ; elles disent quelles tables ajouter. Un plancher
|
||||||
|
// suppose une table au moins.
|
||||||
|
function avecTablesManquantes(configuration, instance, planchers) {
|
||||||
|
const lignes = planchers.map((plancher) => ({ ...plancher, tablesManquantes: null }));
|
||||||
|
if (lignes.length === 0) return lignes;
|
||||||
|
const enAttente = (m) => lignes.some((l) => l.tablesManquantes === null && m <= l.effectif);
|
||||||
|
const capacite = Math.max(...instance.capacite);
|
||||||
|
const premierId = Math.max(...instance.idsTables) + 1;
|
||||||
|
const ajoutees = [];
|
||||||
|
for (let m = 1; enAttente(m); m += 1) {
|
||||||
|
ajoutees.push({ id: premierId + m - 1, numero: configuration.tables.length + m, capacite });
|
||||||
|
const sonde = normaliser({ ...configuration, tables: [...configuration.tables, ...ajoutees] });
|
||||||
|
const restants = new Set(planchersCollisions(sonde).map(({ groupe }) => groupe));
|
||||||
|
for (const ligne of lignes) {
|
||||||
|
if (ligne.tablesManquantes === null && m <= ligne.effectif && !restants.has(ligne.groupe)) {
|
||||||
|
ligne.tablesManquantes = m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lignes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plus grand R′ < R où la redondance minimale s'annule, null quand elle vaut
|
||||||
|
// déjà 0 ou null, ou qu'aucun R′ ne l'annule. La sonde à R′ tours garde les
|
||||||
|
// réservations de portée « tous », qui couvrent alors ses R′ tours, et retire
|
||||||
|
// celles d'un tour au-delà de R′, exclus compris : normaliser les refuserait
|
||||||
|
// (RESERVATION_TOUR). Les R′ se sondent du plus grand au plus petit.
|
||||||
|
function toursSansRedondance(configuration, R, minimale) {
|
||||||
|
if (minimale === null || minimale === 0) return null;
|
||||||
|
for (let tours = R - 1; tours >= 1; tours -= 1) {
|
||||||
|
const reservations = configuration.reservations.filter(
|
||||||
|
({ portee, tour }) => portee !== 'tour' || tour <= tours,
|
||||||
|
);
|
||||||
|
const sonde = normaliser({ ...configuration, tours, reservations });
|
||||||
|
if (redondanceMinimale(sonde, plafondsAPriori(sonde)) === 0) return tours;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vrai quand la séparation est active et que les présents portent tous une
|
||||||
|
// même appartenance, la seule (§ 15.4) ; faux dès qu'un présent est sans
|
||||||
|
// appartenance : la séparation y garde un sens.
|
||||||
|
function uneSeuleAppartenance({ groupe, groupes, contraintes }) {
|
||||||
|
return contraintes.separerAppartenances && groupes.length === 1 && !groupe.includes(SANS_GROUPE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vrai quand tout plan qui assied chacun porte une collision à chaque table
|
||||||
|
// de chaque tour (§ 7.5). Les autres tables tiennent au plus Σ c − c_t
|
||||||
|
// personnes : la table t en reçoit au moins N − (Σ c − c_t). Sans collision,
|
||||||
|
// elle n'assied qu'un membre de chacune des G appartenances et les U présents
|
||||||
|
// sans appartenance : au-delà de G + U, la collision est forcée. Faux quand
|
||||||
|
// la séparation est inactive, qu'une place manque — aucun plan n'assied
|
||||||
|
// chacun —, ou sans table : rien ne le prouve.
|
||||||
|
function collisionPartout(instance, placesManquantes) {
|
||||||
|
const { N, T, capacite, groupe, groupes, contraintes } = instance;
|
||||||
|
if (!contraintes.separerAppartenances || placesManquantes > 0 || T === 0) return false;
|
||||||
|
let sansAppartenance = 0;
|
||||||
|
for (let p = 0; p < N; p += 1) if (groupe[p] === SANS_GROUPE) sansAppartenance += 1;
|
||||||
|
const places = nombrePlaces(instance);
|
||||||
|
for (let t = 0; t < T; t += 1) {
|
||||||
|
if (N - (places - capacite[t]) <= groupes.length + sansAppartenance) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plafond du meilleur itinéraire d'un mobile p qui passe par la table t : le
|
// Plafond du meilleur itinéraire d'un mobile p qui passe par la table t : le
|
||||||
|
|
@ -249,19 +368,23 @@ function etendue(valeurs) {
|
||||||
*
|
*
|
||||||
* - placesManquantes : nombrePlacesManquantes ; la recherche refuse une
|
* - placesManquantes : nombrePlacesManquantes ; la recherche refuse une
|
||||||
* configuration où elles sont positives, le diagnostic les chiffre (§ 5.9).
|
* configuration où elles sont positives, le diagnostic les chiffre (§ 5.9).
|
||||||
* - plafondAPriori : plafondsAPriori, et son étendue.
|
* - plafondAPriori : plafondsAPriori, son étendue et sa répartition par
|
||||||
|
* valeur.
|
||||||
* - collisions : planchers de collisions par groupe, posés sur les tours
|
* - collisions : planchers de collisions par groupe, posés sur les tours
|
||||||
* fixés, capacités relâchées.
|
* fixés, capacités relâchées ; chacun avec les tables qui l'annulent.
|
||||||
* - redondanceMinimale : max_p max(0, min(a priori(p), affiliés autres que
|
* - redondanceMinimale : max_p max(0, min(a priori(p), affiliés autres que
|
||||||
* p) − G).
|
* p) − G) ; remedes.toursSansRedondance, le nombre de tours qui l'annule.
|
||||||
* - animateursMemeAppartenance, et redondanceImposeeParAnimateurs : la forme
|
* - animateursMemeAppartenance, et redondanceImposeeParAnimateurs : la forme
|
||||||
* close min(R, T) − 1 du § 5.6, qui compte un animateur à chaque table
|
* close min(R, T) − 1 du § 5.6, qui compte un animateur à chaque table
|
||||||
* visitée ; 0 quand les animateurs ne partagent pas une appartenance.
|
* visitée ; 0 quand les animateurs ne partagent pas une appartenance.
|
||||||
* - ancrage : ancrés, capacité et gel de chaque table.
|
* - uneSeuleAppartenance et collisionPartout : une séparation vide de sens,
|
||||||
|
* une collision forcée à chaque table.
|
||||||
|
* - ancrage : ancrés, capacité, gel et demi-ancrage de chaque table.
|
||||||
* - retoursImposes : la rangée de instance.fixe se lit comme le plan qui
|
* - retoursImposes : la rangée de instance.fixe se lit comme le plan qui
|
||||||
* assied chacun aux seuls tours fixés, en réserve ailleurs ; sa mesure
|
* assied chacun aux seuls tours fixés, en réserve ailleurs ; sa mesure
|
||||||
* compte max(0, f − 1) retours pour f tours réservés à une même table, ce
|
* compte max(0, f − 1) retours pour f tours réservés à une même table, ce
|
||||||
* que mesure tout plan qui honore les réservations (§ 5.4).
|
* que mesure tout plan qui honore les réservations (§ 5.4) ;
|
||||||
|
* ancragesImposantRetours, les personnes qui en portent un au moins.
|
||||||
* - ecartItineraire : plancher de l'écart d'itinéraire maximal, ou null.
|
* - ecartItineraire : plancher de l'écart d'itinéraire maximal, ou null.
|
||||||
*
|
*
|
||||||
* @param {import('./types.js').Configuration} configuration
|
* @param {import('./types.js').Configuration} configuration
|
||||||
|
|
@ -271,15 +394,26 @@ export function diagnostiquer(configuration) {
|
||||||
const instance = normaliser(configuration);
|
const instance = normaliser(configuration);
|
||||||
const aPriori = plafondsAPriori(instance);
|
const aPriori = plafondsAPriori(instance);
|
||||||
const memeAppartenance = animateursMemeAppartenance(instance);
|
const memeAppartenance = animateursMemeAppartenance(instance);
|
||||||
|
const placesManquantes = nombrePlacesManquantes(instance);
|
||||||
|
const minimale = redondanceMinimale(instance, aPriori);
|
||||||
|
const imposes = mesurer(instance, instance.fixe);
|
||||||
return {
|
return {
|
||||||
placesManquantes: nombrePlacesManquantes(instance),
|
placesManquantes,
|
||||||
plafondAPriori: { ...etendue(aPriori), parPersonne: aPriori },
|
plafondAPriori: {
|
||||||
collisions: planchersCollisions(instance),
|
...etendue(aPriori),
|
||||||
redondanceMinimale: redondanceMinimale(instance, aPriori),
|
parPersonne: aPriori,
|
||||||
|
repartition: repartition(aPriori, instance.statut),
|
||||||
|
},
|
||||||
|
collisions: avecTablesManquantes(configuration, instance, planchersCollisions(instance)),
|
||||||
|
redondanceMinimale: minimale,
|
||||||
|
remedes: { toursSansRedondance: toursSansRedondance(configuration, instance.R, minimale) },
|
||||||
animateursMemeAppartenance: memeAppartenance,
|
animateursMemeAppartenance: memeAppartenance,
|
||||||
redondanceImposeeParAnimateurs: memeAppartenance ? Math.min(instance.R, instance.T) - 1 : 0,
|
redondanceImposeeParAnimateurs: memeAppartenance ? Math.min(instance.R, instance.T) - 1 : 0,
|
||||||
|
uneSeuleAppartenance: uneSeuleAppartenance(instance),
|
||||||
|
collisionPartout: collisionPartout(instance, placesManquantes),
|
||||||
ancrage: ancrage(instance),
|
ancrage: ancrage(instance),
|
||||||
retoursImposes: mesurer(instance, instance.fixe).totalRetoursImposes,
|
retoursImposes: imposes.totalRetoursImposes,
|
||||||
|
ancragesImposantRetours: imposes.retoursImposes.filter((retours) => retours > 0).length,
|
||||||
ecartItineraire: ecartItineraire(configuration, instance, aPriori),
|
ecartItineraire: ecartItineraire(configuration, instance, aPriori),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { describe, test } from '../../test/lanceur.js';
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js';
|
import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js';
|
||||||
|
import { conflitsParTable } from './conflits.js';
|
||||||
import { LIBRE, STATUT, indexerPlan, normaliser } from './configuration.js';
|
import { LIBRE, STATUT, indexerPlan, normaliser } from './configuration.js';
|
||||||
import { diagnostiquer } from './diagnostic.js';
|
import { diagnostiquer } from './diagnostic.js';
|
||||||
import { ErreurConfiguration } from './erreurs.js';
|
import { ErreurConfiguration } from './erreurs.js';
|
||||||
|
|
@ -23,15 +24,31 @@ const SANS_CONTRAINTE = {
|
||||||
nouvelleTable: false,
|
nouvelleTable: false,
|
||||||
varierAppartenances: false,
|
varierAppartenances: false,
|
||||||
};
|
};
|
||||||
|
const TOUTES = {
|
||||||
|
separerAppartenances: true,
|
||||||
|
nouveauxVoisins: true,
|
||||||
|
nouvelleTable: true,
|
||||||
|
varierAppartenances: true,
|
||||||
|
};
|
||||||
|
const SEPARER = { ...SANS_CONTRAINTE, separerAppartenances: true };
|
||||||
|
const TOUTES_SAUF_SEPARER = { ...TOUTES, separerAppartenances: false };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration écrite à la main. tables porte une paire [id, capacité] par
|
* Configuration écrite à la main. tables porte une paire [id, capacité] par
|
||||||
* table, dans l'ordre de la configuration, le numéro suivant la position ;
|
* table, dans l'ordre de la configuration, le numéro suivant la position ;
|
||||||
* appartenances[j] est celle du participant d'id j + 1 ; exclus nomme des ids.
|
* appartenances[j] est celle du participant d'id j + 1 ; exclus nomme des ids ;
|
||||||
|
* contraintes, aucune par défaut.
|
||||||
* Les identifiants de table diffèrent des index et des numéros : un index lu
|
* Les identifiants de table diffèrent des index et des numéros : un index lu
|
||||||
* comme un identifiant désigne une table absente.
|
* comme un identifiant désigne une table absente.
|
||||||
*/
|
*/
|
||||||
function salle({ tables, appartenances, tours, reservations = [], exclus = [] }) {
|
function salle({
|
||||||
|
tables,
|
||||||
|
appartenances,
|
||||||
|
tours,
|
||||||
|
reservations = [],
|
||||||
|
exclus = [],
|
||||||
|
contraintes = SANS_CONTRAINTE,
|
||||||
|
}) {
|
||||||
return {
|
return {
|
||||||
participants: appartenances.map((appartenance, j) => ({
|
participants: appartenances.map((appartenance, j) => ({
|
||||||
id: j + 1,
|
id: j + 1,
|
||||||
|
|
@ -42,7 +59,7 @@ function salle({ tables, appartenances, tours, reservations = [], exclus = [] })
|
||||||
tables: tables.map(([id, capacite], i) => ({ id, numero: i + 1, capacite })),
|
tables: tables.map(([id, capacite], i) => ({ id, numero: i + 1, capacite })),
|
||||||
tours,
|
tours,
|
||||||
reservations,
|
reservations,
|
||||||
contraintes: SANS_CONTRAINTE,
|
contraintes: { ...contraintes },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,8 +234,20 @@ describe('diagnostic : le catalogue (§ 15)', () => {
|
||||||
ancres: 1,
|
ancres: 1,
|
||||||
capacite: t < 29 ? 8 : 7,
|
capacite: t < 29 ? 8 : 7,
|
||||||
gelee: false,
|
gelee: false,
|
||||||
|
moitie: false,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
// 24 aux quatre animateurs des tables de 7, 28 à chacun des autres ; les
|
||||||
|
// 227 mobiles sont tous à 28.
|
||||||
|
assert.deepEqual(diagnostic.plafondAPriori.repartition, [
|
||||||
|
{ valeur: 24, ancres: 4, mobiles: 0 },
|
||||||
|
{ valeur: 28, ancres: 29, mobiles: 227 },
|
||||||
|
]);
|
||||||
|
// Chacun des 33 animateurs revient trois fois à sa table.
|
||||||
|
assert.equal(diagnostic.ancragesImposantRetours, 33);
|
||||||
|
assert.equal(diagnostic.uneSeuleAppartenance, false);
|
||||||
|
assert.equal(diagnostic.collisionPartout, false);
|
||||||
|
assert.deepEqual(diagnostic.remedes, { toursSansRedondance: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 264 sièges pour 260 membres : la configuration n'est pas tendue.
|
// 264 sièges pour 260 membres : la configuration n'est pas tendue.
|
||||||
|
|
@ -250,6 +279,7 @@ describe('diagnostic : le catalogue (§ 15)', () => {
|
||||||
plancherCumulees: 4,
|
plancherCumulees: 4,
|
||||||
plancherPairesDistinctes: 1,
|
plancherPairesDistinctes: 1,
|
||||||
plancherExcedent: null,
|
plancherExcedent: null,
|
||||||
|
tablesManquantes: 1,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
@ -403,6 +433,7 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
|
||||||
plancherCumulees: 5,
|
plancherCumulees: 5,
|
||||||
plancherPairesDistinctes: 1,
|
plancherPairesDistinctes: 1,
|
||||||
plancherExcedent: 2,
|
plancherExcedent: 2,
|
||||||
|
tablesManquantes: 1,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(minimumsParGroupe(configuration).get('X'), {
|
assert.deepEqual(minimumsParGroupe(configuration).get('X'), {
|
||||||
|
|
@ -430,7 +461,14 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
|
||||||
tours: 4,
|
tours: 4,
|
||||||
});
|
});
|
||||||
assert.deepEqual(diagnostiquer(configuration).collisions, [
|
assert.deepEqual(diagnostiquer(configuration).collisions, [
|
||||||
{ groupe: 'X', effectif: 6, plancherCumulees: 8, plancherPairesDistinctes: 2, plancherExcedent: null },
|
{
|
||||||
|
groupe: 'X',
|
||||||
|
effectif: 6,
|
||||||
|
plancherCumulees: 8,
|
||||||
|
plancherPairesDistinctes: 2,
|
||||||
|
plancherExcedent: null,
|
||||||
|
tablesManquantes: 2,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -441,7 +479,14 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
|
||||||
tours: 2,
|
tours: 2,
|
||||||
});
|
});
|
||||||
assert.deepEqual(diagnostiquer(configuration).collisions, [
|
assert.deepEqual(diagnostiquer(configuration).collisions, [
|
||||||
{ groupe: 'X', effectif: 6, plancherCumulees: 12, plancherPairesDistinctes: 6, plancherExcedent: null },
|
{
|
||||||
|
groupe: 'X',
|
||||||
|
effectif: 6,
|
||||||
|
plancherCumulees: 12,
|
||||||
|
plancherPairesDistinctes: 6,
|
||||||
|
plancherExcedent: null,
|
||||||
|
tablesManquantes: 4,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
const exacts = minimumsParGroupe(configuration).get('X');
|
const exacts = minimumsParGroupe(configuration).get('X');
|
||||||
assert.equal(exacts.cumulees, 12);
|
assert.equal(exacts.cumulees, 12);
|
||||||
|
|
@ -455,7 +500,14 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
|
||||||
tours: 3,
|
tours: 3,
|
||||||
});
|
});
|
||||||
assert.deepEqual(diagnostiquer(configuration).collisions, [
|
assert.deepEqual(diagnostiquer(configuration).collisions, [
|
||||||
{ groupe: 'X', effectif: 5, plancherCumulees: 12, plancherPairesDistinctes: 4, plancherExcedent: 2 },
|
{
|
||||||
|
groupe: 'X',
|
||||||
|
effectif: 5,
|
||||||
|
plancherCumulees: 12,
|
||||||
|
plancherPairesDistinctes: 4,
|
||||||
|
plancherExcedent: 2,
|
||||||
|
tablesManquantes: 3,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(minimumsParGroupe(configuration).get('X'), { cumulees: 12, distinctes: 4, excedent: 2 });
|
assert.deepEqual(minimumsParGroupe(configuration).get('X'), { cumulees: 12, distinctes: 4, excedent: 2 });
|
||||||
});
|
});
|
||||||
|
|
@ -473,6 +525,7 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
|
||||||
plancherCumulees: 4,
|
plancherCumulees: 4,
|
||||||
plancherPairesDistinctes: 1,
|
plancherPairesDistinctes: 1,
|
||||||
plancherExcedent: 1,
|
plancherExcedent: 1,
|
||||||
|
tablesManquantes: 1,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(minimumsParGroupe(configuration).get('X'), {
|
assert.deepEqual(minimumsParGroupe(configuration).get('X'), {
|
||||||
|
|
@ -502,6 +555,7 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
|
||||||
plancherCumulees: 3,
|
plancherCumulees: 3,
|
||||||
plancherPairesDistinctes: 1,
|
plancherPairesDistinctes: 1,
|
||||||
plancherExcedent: null,
|
plancherExcedent: null,
|
||||||
|
tablesManquantes: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
groupe: 'Y',
|
groupe: 'Y',
|
||||||
|
|
@ -509,6 +563,7 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
|
||||||
plancherCumulees: 1,
|
plancherCumulees: 1,
|
||||||
plancherPairesDistinctes: 1,
|
plancherPairesDistinctes: 1,
|
||||||
plancherExcedent: null,
|
plancherExcedent: null,
|
||||||
|
tablesManquantes: null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const minimums = minimumsParGroupe(configuration);
|
const minimums = minimumsParGroupe(configuration);
|
||||||
|
|
@ -532,6 +587,7 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
|
||||||
plancherCumulees: 3,
|
plancherCumulees: 3,
|
||||||
plancherPairesDistinctes: 1,
|
plancherPairesDistinctes: 1,
|
||||||
plancherExcedent: 2,
|
plancherExcedent: 2,
|
||||||
|
tablesManquantes: null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
@ -552,6 +608,7 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => {
|
||||||
plancherCumulees: 2,
|
plancherCumulees: 2,
|
||||||
plancherPairesDistinctes: 1,
|
plancherPairesDistinctes: 1,
|
||||||
plancherExcedent: null,
|
plancherExcedent: null,
|
||||||
|
tablesManquantes: 1,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
@ -933,9 +990,9 @@ describe('diagnostic : ancrage et retours imposés (§ 5.4, § 5.6)', () => {
|
||||||
reservations: [ancrer(1, 30), ancrer(2, 30), ancrer(3, 10), ancrer(4, 10)],
|
reservations: [ancrer(1, 30), ancrer(2, 30), ancrer(3, 10), ancrer(4, 10)],
|
||||||
});
|
});
|
||||||
assert.deepEqual(diagnostiquer(configuration).ancrage, [
|
assert.deepEqual(diagnostiquer(configuration).ancrage, [
|
||||||
{ table: 30, ancres: 2, capacite: 2, gelee: true },
|
{ table: 30, ancres: 2, capacite: 2, gelee: true, moitie: false },
|
||||||
{ table: 10, ancres: 2, capacite: 3, gelee: false },
|
{ table: 10, ancres: 2, capacite: 3, gelee: false, moitie: true },
|
||||||
{ table: 20, ancres: 0, capacite: 2, gelee: false },
|
{ table: 20, ancres: 0, capacite: 2, gelee: false, moitie: false },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -964,6 +1021,8 @@ describe('diagnostic : ancrage et retours imposés (§ 5.4, § 5.6)', () => {
|
||||||
});
|
});
|
||||||
const diagnostic = diagnostiquer(configuration);
|
const diagnostic = diagnostiquer(configuration);
|
||||||
assert.equal(diagnostic.retoursImposes, 7);
|
assert.equal(diagnostic.retoursImposes, 7);
|
||||||
|
// 1, 2 et 5 portent des retours imposés ; 3 n'en porte aucun.
|
||||||
|
assert.equal(diagnostic.ancragesImposantRetours, 3);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
diagnostic.ancrage.map(({ ancres }) => ancres),
|
diagnostic.ancrage.map(({ ancres }) => ancres),
|
||||||
[2, 0],
|
[2, 0],
|
||||||
|
|
@ -998,25 +1057,34 @@ describe('diagnostic : population vide, forme, refus', () => {
|
||||||
test('une population vide rend null, jamais 0, là où rien ne se mesure', () => {
|
test('une population vide rend null, jamais 0, là où rien ne se mesure', () => {
|
||||||
const attendu = {
|
const attendu = {
|
||||||
placesManquantes: 0,
|
placesManquantes: 0,
|
||||||
plafondAPriori: { min: null, max: null, parPersonne: [] },
|
plafondAPriori: { min: null, max: null, parPersonne: [], repartition: [] },
|
||||||
collisions: [],
|
collisions: [],
|
||||||
redondanceMinimale: null,
|
redondanceMinimale: null,
|
||||||
|
remedes: { toursSansRedondance: null },
|
||||||
animateursMemeAppartenance: false,
|
animateursMemeAppartenance: false,
|
||||||
redondanceImposeeParAnimateurs: 0,
|
redondanceImposeeParAnimateurs: 0,
|
||||||
ancrage: [{ table: 10, ancres: 0, capacite: 4, gelee: false }],
|
uneSeuleAppartenance: false,
|
||||||
|
collisionPartout: false,
|
||||||
|
ancrage: [{ table: 10, ancres: 0, capacite: 4, gelee: false, moitie: false }],
|
||||||
retoursImposes: 0,
|
retoursImposes: 0,
|
||||||
|
ancragesImposantRetours: 0,
|
||||||
ecartItineraire: null,
|
ecartItineraire: null,
|
||||||
};
|
};
|
||||||
const vide = salle({ tables: [[10, 4]], appartenances: [], tours: 2 });
|
// Les mêmes chiffres, séparation active ou non : sans présent, aucune
|
||||||
assert.deepEqual(diagnostiquer(vide), attendu);
|
// appartenance n'est portée par chacun, et aucune table n'est forcée.
|
||||||
const tousExclus = salle({
|
for (const contraintes of [SANS_CONTRAINTE, TOUTES]) {
|
||||||
tables: [[10, 4]],
|
const vide = salle({ tables: [[10, 4]], appartenances: [], tours: 2, contraintes });
|
||||||
appartenances: ['X', 'X', 'X'],
|
assert.deepEqual(diagnostiquer(vide), attendu);
|
||||||
tours: 2,
|
const tousExclus = salle({
|
||||||
reservations: [ancrer(1, 10), ancrer(2, 10)],
|
tables: [[10, 4]],
|
||||||
exclus: [1, 2, 3],
|
appartenances: ['X', 'X', 'X'],
|
||||||
});
|
tours: 2,
|
||||||
assert.deepEqual(diagnostiquer(tousExclus), attendu);
|
reservations: [ancrer(1, 10), ancrer(2, 10)],
|
||||||
|
exclus: [1, 2, 3],
|
||||||
|
contraintes,
|
||||||
|
});
|
||||||
|
assert.deepEqual(diagnostiquer(tousExclus), attendu);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('le diagnostic ne modifie pas la configuration', () => {
|
test('le diagnostic ne modifie pas la configuration', () => {
|
||||||
|
|
@ -1041,3 +1109,310 @@ describe('diagnostic : population vide, forme, refus', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('diagnostic : répartition du plafond a priori (§ 5.6)', () => {
|
||||||
|
// 1 et 2, sans appartenance, ancrés à la table 10 de 3 sièges ; quatre
|
||||||
|
// mobiles de X, deux tours : les ancrés plafonnent à 3, les mobiles à 4
|
||||||
|
// (voir « un affilié ne se compte pas parmi les affiliés qu'il
|
||||||
|
// rencontre »).
|
||||||
|
test('une ligne par valeur, croissante, ancrés et mobiles comptés à part', () => {
|
||||||
|
const configuration = salle({
|
||||||
|
tables: [[10, 3], [20, 3]],
|
||||||
|
appartenances: [null, null, ...fois(4, 'X')],
|
||||||
|
tours: 2,
|
||||||
|
reservations: [ancrer(1, 10), ancrer(2, 10)],
|
||||||
|
});
|
||||||
|
assert.deepEqual(diagnostiquer(configuration).plafondAPriori.repartition, [
|
||||||
|
{ valeur: 3, ancres: 2, mobiles: 0 },
|
||||||
|
{ valeur: 4, ancres: 0, mobiles: 4 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Un partiellement fixé compte parmi les mobiles (§ 5.4) : 1, réservé à la
|
||||||
|
// table 10 au seul tour 1, plafonne comme les autres, la table 10 restant
|
||||||
|
// libre à l'autre tour. Six personnes sur deux tables de 3, deux tours :
|
||||||
|
// chacun rencontre 2 voisins par tour, 4 en tout.
|
||||||
|
test('un partiellement fixé se compte parmi les mobiles', () => {
|
||||||
|
const configuration = salle({
|
||||||
|
tables: [[10, 3], [20, 3]],
|
||||||
|
appartenances: fois(6, null),
|
||||||
|
tours: 2,
|
||||||
|
reservations: [fixer(1, 10, 1)],
|
||||||
|
});
|
||||||
|
assert.deepEqual(diagnostiquer(configuration).plafondAPriori.repartition, [
|
||||||
|
{ valeur: 4, ancres: 0, mobiles: 6 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('diagnostic : tables manquantes (§ 5.6, § 15.3)', () => {
|
||||||
|
// Le remède compte des tables de la plus grande capacité présente ; le
|
||||||
|
// plancher, capacités relâchées, n'en dépend pas. Tables de 2 et de 6 :
|
||||||
|
// quatre membres de X pour deux tables, deux de plus les séparent.
|
||||||
|
test('des tables de capacités inégales : le compte ne lit que le plancher', () => {
|
||||||
|
const configuration = salle({
|
||||||
|
tables: [[10, 2], [20, 6]],
|
||||||
|
appartenances: [...fois(4, 'X'), ...fois(4, null)],
|
||||||
|
tours: 2,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
diagnostiquer(configuration).collisions.map(({ tablesManquantes }) => tablesManquantes),
|
||||||
|
[2],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deux groupes : Y, réservé ensemble à la table 10 au tour 2, n'a pas de
|
||||||
|
// remède ; X, cinq membres libres sur trois tables, en a un, 2 tables.
|
||||||
|
test('un groupe sans remède n’empêche pas le remède d’un autre', () => {
|
||||||
|
const configuration = salle({
|
||||||
|
tables: [[10, 4], [20, 4], [30, 4]],
|
||||||
|
appartenances: [...fois(5, 'X'), 'Y', 'Y', null],
|
||||||
|
tours: 2,
|
||||||
|
reservations: [fixer(6, 10, 2), fixer(7, 10, 2)],
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
diagnostiquer(configuration).collisions.map(({ groupe, tablesManquantes }) => [groupe, tablesManquantes]),
|
||||||
|
[
|
||||||
|
['X', 2],
|
||||||
|
['Y', null],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('diagnostic : remède en tours (§ 5.6)', () => {
|
||||||
|
// 28 personnes, dix appartenances de 2 et huit de 1, G = 18 ; quatre
|
||||||
|
// tables de 7. Un participant rencontre au plus 6 personnes par tour. En
|
||||||
|
// 4 tours : min(24, 27 affiliés autres que soi) − 18 = 6. En 3 tours :
|
||||||
|
// 18 − 18 = 0.
|
||||||
|
const DIX_PAIRES_HUIT_SEULS = [
|
||||||
|
...Array.from({ length: 10 }, (_, g) => fois(2, `Grisaille ${g + 1}`)).flat(),
|
||||||
|
...Array.from({ length: 8 }, (_, g) => `Club des Merles ${g + 1}`),
|
||||||
|
];
|
||||||
|
const QUATRE_TABLES_DE_SEPT = [[10, 7], [20, 7], [30, 7], [40, 7]];
|
||||||
|
|
||||||
|
test('28 personnes en 18 appartenances, quatre tours : 6 redondances, aucune en 3 tours', () => {
|
||||||
|
assert.equal(DIX_PAIRES_HUIT_SEULS.length, 28);
|
||||||
|
const configuration = salle({
|
||||||
|
tables: QUATRE_TABLES_DE_SEPT,
|
||||||
|
appartenances: DIX_PAIRES_HUIT_SEULS,
|
||||||
|
tours: 4,
|
||||||
|
});
|
||||||
|
const diagnostic = diagnostiquer(configuration);
|
||||||
|
assert.equal(diagnostic.redondanceMinimale, 6);
|
||||||
|
assert.deepEqual(diagnostic.remedes, { toursSansRedondance: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// La réservation du tour 4 ne tient pas dans une configuration de 3
|
||||||
|
// tours : la sonde la retire, et celle d'un exclu aussi, au lieu de lever
|
||||||
|
// RESERVATION_TOUR. La réservation de portée « tous » couvre les trois.
|
||||||
|
test('une réservation d’un tour au-delà de R′ se retire de la sonde, sans lever', () => {
|
||||||
|
const configuration = salle({
|
||||||
|
tables: QUATRE_TABLES_DE_SEPT,
|
||||||
|
appartenances: [...DIX_PAIRES_HUIT_SEULS, 'Club des Merles 1'],
|
||||||
|
tours: 4,
|
||||||
|
reservations: [fixer(1, 10, 4), ancrer(2, 20), fixer(29, 30, 4)],
|
||||||
|
exclus: [29],
|
||||||
|
});
|
||||||
|
assert.deepEqual(diagnostiquer(configuration).remedes, { toursSansRedondance: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trois tables de 2, P1 sans appartenance, P2 et P3 de la même. En 3
|
||||||
|
// tours, P1, fixé à la table 3 à chaque tour, y retrouve P3 au tour 2 et
|
||||||
|
// P2 au tour 3 : 2 − 1 = 1. En 2 tours, P2 et P3 occupent la table 2 au
|
||||||
|
// tour 1 et P1 n'y retrouve que P3 au tour 2 : 1 − 1 = 0. Le remède, 2,
|
||||||
|
// tient aux réservations des tours 1 et 2, que la sonde garde.
|
||||||
|
test('les réservations d’un tour jusqu’à R′ restent dans la sonde', () => {
|
||||||
|
const configuration = salle({
|
||||||
|
tables: [[1, 2], [2, 2], [3, 2]],
|
||||||
|
appartenances: [null, 'Grisaille', 'Grisaille'],
|
||||||
|
tours: 3,
|
||||||
|
reservations: [
|
||||||
|
fixer(1, 3, 1), fixer(1, 3, 2), fixer(1, 3, 3),
|
||||||
|
fixer(2, 2, 1), fixer(2, 2, 2), fixer(2, 3, 3),
|
||||||
|
fixer(3, 2, 1), fixer(3, 3, 2), fixer(3, 2, 3),
|
||||||
|
],
|
||||||
|
contraintes: TOUTES,
|
||||||
|
});
|
||||||
|
const diagnostic = diagnostiquer(configuration);
|
||||||
|
assert.equal(diagnostic.redondanceMinimale, 1);
|
||||||
|
assert.deepEqual(diagnostic.remedes, { toursSansRedondance: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deux tables de 3, P1 sans appartenance ancré à la table 10, P2 et P3 de
|
||||||
|
// la même appartenance, fixés aux tables 10 et 20 aux tours 1 et 2. En 3
|
||||||
|
// tours, le tour 3 libre réunit P1 et P3 : 2 − 1 = 1. En 2 tours, P1 ne
|
||||||
|
// rencontre que P2 : 0. Le remède, 2, tient à l'ancrage, qui couvre les
|
||||||
|
// tours de la sonde, et aux réservations du tour 2, R′ lui-même.
|
||||||
|
test('l’ancrage et les réservations du tour R′ lui-même restent dans la sonde', () => {
|
||||||
|
const configuration = salle({
|
||||||
|
tables: [[10, 3], [20, 3]],
|
||||||
|
appartenances: [null, 'Grisaille', 'Grisaille'],
|
||||||
|
tours: 3,
|
||||||
|
reservations: [ancrer(1, 10), fixer(2, 10, 1), fixer(3, 20, 1), fixer(2, 10, 2), fixer(3, 20, 2)],
|
||||||
|
});
|
||||||
|
const diagnostic = diagnostiquer(configuration);
|
||||||
|
assert.equal(diagnostic.redondanceMinimale, 1);
|
||||||
|
assert.deepEqual(diagnostic.remedes, { toursSansRedondance: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deux appartenances de 14 : en un seul tour, 6 − 2 = 4 ; aucun R′ ne
|
||||||
|
// l'annule.
|
||||||
|
test('deux appartenances de 14 : aucun nombre de tours n’annule la redondance', () => {
|
||||||
|
const configuration = salle({
|
||||||
|
tables: QUATRE_TABLES_DE_SEPT,
|
||||||
|
appartenances: [...fois(14, 'Grisaille'), ...fois(14, 'Club des Merles')],
|
||||||
|
tours: 4,
|
||||||
|
});
|
||||||
|
const diagnostic = diagnostiquer(configuration);
|
||||||
|
assert.equal(diagnostic.redondanceMinimale, 22);
|
||||||
|
assert.deepEqual(diagnostic.remedes, { toursSansRedondance: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sans affilié, ou à redondance déjà nulle : aucun remède', () => {
|
||||||
|
const sansAffilie = salle({ tables: QUATRE_TABLES_DE_SEPT, appartenances: fois(28, null), tours: 4 });
|
||||||
|
assert.deepEqual(diagnostiquer(sansAffilie).remedes, { toursSansRedondance: null });
|
||||||
|
const dejaNulle = salle({ tables: QUATRE_TABLES_DE_SEPT, appartenances: DIX_PAIRES_HUIT_SEULS, tours: 3 });
|
||||||
|
assert.equal(diagnostiquer(dejaNulle).redondanceMinimale, 0);
|
||||||
|
assert.deepEqual(diagnostiquer(dejaNulle).remedes, { toursSansRedondance: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('diagnostic : une seule appartenance (§ 15.4)', () => {
|
||||||
|
const tables = [[10, 3], [20, 3], [30, 3], [40, 3]];
|
||||||
|
|
||||||
|
test('douze personnes d’une appartenance, séparation active : annoncée ; désactivée : non', () => {
|
||||||
|
const active = salle({ tables, appartenances: fois(12, 'Grisaille'), tours: 4, contraintes: SEPARER });
|
||||||
|
assert.equal(diagnostiquer(active).uneSeuleAppartenance, true);
|
||||||
|
const inactive = salle({ tables, appartenances: fois(12, 'Grisaille'), tours: 4, contraintes: TOUTES_SAUF_SEPARER });
|
||||||
|
assert.equal(diagnostiquer(inactive).uneSeuleAppartenance, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Les présents sans appartenance se séparent des trois membres : la
|
||||||
|
// séparation y garde un sens.
|
||||||
|
test('la même appartenance portée par 3 des 12 : faux', () => {
|
||||||
|
const configuration = salle({
|
||||||
|
tables,
|
||||||
|
appartenances: [...fois(3, 'Grisaille'), ...fois(9, null)],
|
||||||
|
tours: 4,
|
||||||
|
contraintes: SEPARER,
|
||||||
|
});
|
||||||
|
assert.equal(diagnostiquer(configuration).uneSeuleAppartenance, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// L'appartenance du seul membre exclu ne compte pas : les présents n'en
|
||||||
|
// portent qu'une.
|
||||||
|
test('un exclu d’une autre appartenance ne compte pas', () => {
|
||||||
|
const configuration = salle({
|
||||||
|
tables,
|
||||||
|
appartenances: [...fois(11, 'Grisaille'), 'Club des Merles'],
|
||||||
|
tours: 4,
|
||||||
|
contraintes: SEPARER,
|
||||||
|
exclus: [12],
|
||||||
|
});
|
||||||
|
assert.equal(diagnostiquer(configuration).uneSeuleAppartenance, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('diagnostic : collision à chaque table (§ 7.5)', () => {
|
||||||
|
// Douze personnes en deux appartenances de six, quatre tables de 3 : une
|
||||||
|
// table reçoit au moins 12 − 9 = 3 personnes, plus que G + U = 2.
|
||||||
|
const DEUX_FOIS_SIX = [...fois(6, 'Grisaille'), ...fois(6, 'Club des Merles')];
|
||||||
|
const QUATRE_DE_TROIS = [[10, 3], [20, 3], [30, 3], [40, 3]];
|
||||||
|
|
||||||
|
test('deux appartenances de six sur quatre tables de 3 : vrai ; désactivée, faux ; une cinquième table, faux', () => {
|
||||||
|
const active = salle({ tables: QUATRE_DE_TROIS, appartenances: DEUX_FOIS_SIX, tours: 4, contraintes: SEPARER });
|
||||||
|
assert.equal(diagnostiquer(active).collisionPartout, true);
|
||||||
|
const inactive = salle({
|
||||||
|
tables: QUATRE_DE_TROIS,
|
||||||
|
appartenances: DEUX_FOIS_SIX,
|
||||||
|
tours: 4,
|
||||||
|
contraintes: TOUTES_SAUF_SEPARER,
|
||||||
|
});
|
||||||
|
assert.equal(diagnostiquer(inactive).collisionPartout, false);
|
||||||
|
// Une table de 3 de plus : chaque table peut rester vide, 12 − 12 = 0.
|
||||||
|
const cinqTables = salle({
|
||||||
|
tables: [...QUATRE_DE_TROIS, [50, 3]],
|
||||||
|
appartenances: DEUX_FOIS_SIX,
|
||||||
|
tours: 4,
|
||||||
|
contraintes: SEPARER,
|
||||||
|
});
|
||||||
|
assert.equal(diagnostiquer(cinqTables).collisionPartout, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trois membres de chacune des trois appartenances par table de 3 : un
|
||||||
|
// plan sans collision existe, le plan parfait.
|
||||||
|
test('la petite : faux', () => {
|
||||||
|
assert.equal(diagnostiquer(construire('petite')).collisionPartout, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Un présent sans appartenance : 6 − 3 = 3 places au moins par table, et
|
||||||
|
// G + U = 3 personnes s'y assoient sans collision.
|
||||||
|
test('un présent sans appartenance peut rompre la preuve : faux', () => {
|
||||||
|
const configuration = salle({
|
||||||
|
tables: [[10, 3], [20, 3]],
|
||||||
|
appartenances: ['Grisaille', 'Grisaille', 'Grisaille', 'Club des Merles', 'Club des Merles', null],
|
||||||
|
tours: 2,
|
||||||
|
contraintes: SEPARER,
|
||||||
|
});
|
||||||
|
assert.equal(diagnostiquer(configuration).collisionPartout, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sept personnes pour six sièges : aucun plan n'assied chacun, rien ne se
|
||||||
|
// prouve. Sans table, rien non plus.
|
||||||
|
test('des places manquantes, ou aucune table : faux', () => {
|
||||||
|
const tropPetite = salle({
|
||||||
|
tables: [[10, 3], [20, 3]],
|
||||||
|
appartenances: [...fois(4, 'Grisaille'), ...fois(3, 'Club des Merles')],
|
||||||
|
tours: 2,
|
||||||
|
contraintes: SEPARER,
|
||||||
|
});
|
||||||
|
assert.equal(diagnostiquer(tropPetite).placesManquantes, 1);
|
||||||
|
assert.equal(diagnostiquer(tropPetite).collisionPartout, false);
|
||||||
|
const sansTable = salle({ tables: [], appartenances: [], tours: 2, contraintes: SEPARER });
|
||||||
|
assert.equal(diagnostiquer(sansTable).collisionPartout, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deux appartenances de trois, deux tables de 3, deux tours : 6 − 3 = 3
|
||||||
|
// > 2. L'énumération le confirme sans lire le diagnostic : chacun des
|
||||||
|
// C(6, 3)² = 400 plans porte une violation de séparation à chaque table de
|
||||||
|
// chaque tour.
|
||||||
|
test('deux appartenances de trois sur deux tables de 3 : vrai, et chacun des 400 plans le confirme', () => {
|
||||||
|
const configuration = salle({
|
||||||
|
tables: [[10, 3], [20, 3]],
|
||||||
|
appartenances: [...fois(3, 'Grisaille'), ...fois(3, 'Club des Merles')],
|
||||||
|
tours: 2,
|
||||||
|
contraintes: SEPARER,
|
||||||
|
});
|
||||||
|
assert.equal(diagnostiquer(configuration).collisionPartout, true);
|
||||||
|
const instance = normaliser(configuration);
|
||||||
|
const fautes = [];
|
||||||
|
const plans = enumererPlans(instance, (tableDe) => {
|
||||||
|
const cases = conflitsParTable(instance, tableDe).flat();
|
||||||
|
if (cases.length !== 4) fautes.push(`${cases.length} cases`);
|
||||||
|
for (const { table, regles } of cases) {
|
||||||
|
if (regles.every(({ regle }) => regle !== 'separerAppartenances')) {
|
||||||
|
fautes.push(`table ${table} sans collision : ${Array.from(tableDe)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert.equal(plans, 400);
|
||||||
|
assert.deepEqual(fautes, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('diagnostic : ancrage par table (§ 5.6)', () => {
|
||||||
|
test('trois ancrés pour trois sièges : gelée, non à moitié ; deux pour quatre : à moitié', () => {
|
||||||
|
const configuration = salle({
|
||||||
|
tables: [[10, 3], [20, 4], [30, 4]],
|
||||||
|
appartenances: fois(9, null),
|
||||||
|
tours: 2,
|
||||||
|
reservations: [ancrer(1, 10), ancrer(2, 10), ancrer(3, 10), ancrer(4, 20), ancrer(5, 20), ancrer(6, 30)],
|
||||||
|
});
|
||||||
|
assert.deepEqual(diagnostiquer(configuration).ancrage, [
|
||||||
|
{ table: 10, ancres: 3, capacite: 3, gelee: true, moitie: false },
|
||||||
|
{ table: 20, ancres: 2, capacite: 4, gelee: false, moitie: true },
|
||||||
|
{ table: 30, ancres: 1, capacite: 4, gelee: false, moitie: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,12 @@
|
||||||
// sans appartenance ne sont pas collègues. Un ensemble vide rend null, jamais
|
// sans appartenance ne sont pas collègues. Un ensemble vide rend null, jamais
|
||||||
// 0 : le taux de diversité de qui ne rencontre aucun affilié, le minimum et la
|
// 0 : le taux de diversité de qui ne rencontre aucun affilié, le minimum et la
|
||||||
// moyenne d'une population sans membre.
|
// moyenne d'une population sans membre.
|
||||||
|
//
|
||||||
|
// Une seule lecture du plan décide qu'une paire est réunie, collègue ou revue,
|
||||||
|
// et qu'une personne revient : parcourirTours. mesurer en tire chacun de ses
|
||||||
|
// chiffres, et conflitsParTable (conflits.js) le détail par table, si bien
|
||||||
|
// que la somme des violations par table égale le compte de la mesure
|
||||||
|
// (§ 13.2).
|
||||||
import {
|
import {
|
||||||
RESERVE,
|
RESERVE,
|
||||||
SANS_GROUPE,
|
SANS_GROUPE,
|
||||||
|
|
@ -41,10 +47,15 @@ import {
|
||||||
* @property {number[]} redondance r(p) = |F(p)| − A(p)
|
* @property {number[]} redondance r(p) = |F(p)| − A(p)
|
||||||
* @property {(number|null)[]} diversite d(p) = A(p) / |F(p)|, null si |F(p)| = 0
|
* @property {(number|null)[]} diversite d(p) = A(p) / |F(p)|, null si |F(p)| = 0
|
||||||
* @property {number[]} toursAssis
|
* @property {number[]} toursAssis
|
||||||
* @property {number[]} retoursChoisis par personne
|
* @property {number[]} retoursChoisis par personne : ses occurrences
|
||||||
* @property {number[]} retoursImposes par personne
|
* parmi les revenus de parcourirTours
|
||||||
|
* @property {number[]} retoursImposes par personne : ses retours moins
|
||||||
|
* ses retours choisis
|
||||||
* @property {TroisAgregats} aggRencontres
|
* @property {TroisAgregats} aggRencontres
|
||||||
* @property {TroisAgregats} aggRedondance
|
* @property {TroisAgregats} aggRedondance
|
||||||
|
* @property {TroisAgregats} aggAppartenancesVues A(p)
|
||||||
|
* @property {TroisAgregats} aggDiversite d(p), sur les seules personnes qui
|
||||||
|
* rencontrent un affilié
|
||||||
* @property {number} totalRedondance Σ_p r(p), le seul total de la
|
* @property {number} totalRedondance Σ_p r(p), le seul total de la
|
||||||
* redondance : la recherche et le
|
* redondance : la recherche et le
|
||||||
* classement le lisent
|
* classement le lisent
|
||||||
|
|
@ -53,7 +64,8 @@ import {
|
||||||
* @property {number} excedentCollisions cumulées − distinctes
|
* @property {number} excedentCollisions cumulées − distinctes
|
||||||
* @property {{choisies: number, imposees: number}} rencontresRepetees
|
* @property {{choisies: number, imposees: number}} rencontresRepetees
|
||||||
* paires réunies ≥ 2 fois ; « imposée » quand chaque rencontre de la
|
* paires réunies ≥ 2 fois ; « imposée » quand chaque rencontre de la
|
||||||
* paire a lieu à une table où les deux sont réservés ce tour-là
|
* paire a lieu à une table où les deux sont réservés ce tour-là ;
|
||||||
|
* choisies, les paires distinctes des revues de parcourirTours
|
||||||
* @property {number} maxRencontresPaire sur toutes les paires ; 0 si aucune
|
* @property {number} maxRencontresPaire sur toutes les paires ; 0 si aucune
|
||||||
* @property {number} totalRetoursChoisis
|
* @property {number} totalRetoursChoisis
|
||||||
* @property {number} totalRetoursImposes
|
* @property {number} totalRetoursImposes
|
||||||
|
|
@ -70,57 +82,122 @@ const UINT16_MAX = 0xffff;
|
||||||
// (N − 1) + (N − 2) + … + (N − a) paires des lignes qui la précèdent.
|
// (N − 1) + (N − 2) + … + (N − a) paires des lignes qui la précèdent.
|
||||||
const rangPaire = (N, a, b) => (a * (2 * N - a - 1)) / 2 + (b - a - 1);
|
const rangPaire = (N, a, b) => (a * (2 * N - a - 1)) / 2 + (b - a - 1);
|
||||||
|
|
||||||
// Rencontres de chaque paire, rangées par rangPaire, en parcourant chaque
|
/**
|
||||||
// table de chaque tour. choisie vaut 1 pour une paire dont une rencontre au
|
* Le plan lu tour par tour, puis table par table, par index croissants : le
|
||||||
// moins a lieu hors d'une table où les deux sont réservés ce tour-là. Une
|
* seul code qui décide qu'une paire est réunie, de même appartenance ou
|
||||||
// paire se réunit au plus une fois par tour : R borne chaque compte, qu'un
|
* revue, et qu'une personne revient. mesurer en tire chacun de ses chiffres,
|
||||||
// Uint16Array tient tant que R ≤ 65 535.
|
* conflitsParTable (conflits.js) le détail par table.
|
||||||
function compterRencontres({ N, T, R, fixe }, tableDe) {
|
*
|
||||||
const paires = (N * (N - 1)) / 2;
|
* parTour[r][t], en index de personnes, chaque paire [a, b] avec a < b,
|
||||||
const compte = R <= UINT16_MAX ? new Uint16Array(paires) : new Uint32Array(paires);
|
* chaque liste croissante — paires dans l'ordre de a, puis de b :
|
||||||
const choisie = new Uint8Array(paires);
|
* - collegues : paires de même appartenance assises à t au tour r ;
|
||||||
|
* - revues : paires assises à t au tour r et réunies à un tour antérieur,
|
||||||
|
* sauf quand chacune de leurs rencontres jusqu'à r a lieu à une table où
|
||||||
|
* les deux sont réservés ce tour-là ;
|
||||||
|
* - revenus : personnes assises à t au tour r et à un tour antérieur, sauf
|
||||||
|
* quand elles y sont réservées au tour r et lors d'une visite antérieure
|
||||||
|
* à t.
|
||||||
|
* parPaire : les rencontres de chaque paire, rangées par rangPaire ; une
|
||||||
|
* paire se réunit au plus une fois par tour, R borne chaque compte, qu'un
|
||||||
|
* Uint16Array tient tant que R ≤ 65 535. toursAssis, et retours — visites
|
||||||
|
* d'une table déjà visitée, imposées comprises —, par personne.
|
||||||
|
*
|
||||||
|
* Ne modifie ni l'instance ni le plan ; lève ce que lève exigerPlanIndexe.
|
||||||
|
*
|
||||||
|
* @param {import('./types.js').Instance} instance
|
||||||
|
* @param {ArrayLike<number>} tableDe
|
||||||
|
* @returns {{parTour: Array<Array<{collegues: number[][], revues: number[][], revenus: number[]}>>,
|
||||||
|
* parPaire: Uint16Array|Uint32Array, toursAssis: number[], retours: number[]}}
|
||||||
|
*/
|
||||||
|
export function parcourirTours(instance, tableDe) {
|
||||||
|
exigerPlanIndexe(instance, tableDe);
|
||||||
|
const { N, T, R, fixe, groupe } = instance;
|
||||||
|
|
||||||
|
// Personne par personne, ses tours dans l'ordre : visites[t] compte ses
|
||||||
|
// passages à la table t, reserveeAvant[t] note qu'un passage antérieur y
|
||||||
|
// était réservé. revenu[p × R + r] marque la visite répétée que la
|
||||||
|
// personne choisit. Le second parcours de ses tours remet à zéro les
|
||||||
|
// tables qu'elle a visitées : la personne suivante les trouve nulles.
|
||||||
|
const toursAssis = new Array(N).fill(0);
|
||||||
|
const retours = new Array(N).fill(0);
|
||||||
|
const revenu = new Uint8Array(N * R);
|
||||||
|
const visites = new Int32Array(T);
|
||||||
|
const reserveeAvant = new Uint8Array(T);
|
||||||
|
for (let p = 0; p < N; p += 1) {
|
||||||
|
for (let r = 0; r < R; r += 1) {
|
||||||
|
const t = tableDe[p * R + r];
|
||||||
|
if (t === RESERVE) continue;
|
||||||
|
toursAssis[p] += 1;
|
||||||
|
const reservee = fixe[p * R + r] === t;
|
||||||
|
if (visites[t] > 0) {
|
||||||
|
retours[p] += 1;
|
||||||
|
if (!reservee || reserveeAvant[t] === 0) revenu[p * R + r] = 1;
|
||||||
|
}
|
||||||
|
visites[t] += 1;
|
||||||
|
if (reservee) reserveeAvant[t] = 1;
|
||||||
|
}
|
||||||
|
for (let r = 0; r < R; r += 1) {
|
||||||
|
const t = tableDe[p * R + r];
|
||||||
|
if (t === RESERVE) continue;
|
||||||
|
visites[t] = 0;
|
||||||
|
reserveeAvant[t] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tour par tour : occupants de chaque table, par index croissants, a < b
|
||||||
|
// pour toute paire (liste[i], liste[j]) avec i < j. La boucle intérieure
|
||||||
|
// part du voisin suivant : une liste vide ou d'un seul occupant ne forme
|
||||||
|
// aucune paire, et nul n'est apparié à lui-même. libre[k] note qu'une
|
||||||
|
// rencontre de la paire, jusqu'au tour courant compris, a eu lieu hors
|
||||||
|
// d'une table où les deux sont réservés.
|
||||||
|
const nombrePaires = (N * (N - 1)) / 2;
|
||||||
|
const parPaire = R <= UINT16_MAX ? new Uint16Array(nombrePaires) : new Uint32Array(nombrePaires);
|
||||||
|
const libre = new Uint8Array(nombrePaires);
|
||||||
|
const parTour = [];
|
||||||
for (let r = 0; r < R; r += 1) {
|
for (let r = 0; r < R; r += 1) {
|
||||||
// Occupants de chaque table, par index croissants : a < b pour toute
|
|
||||||
// paire (liste[i], liste[j]) avec i < j. La boucle intérieure part du
|
|
||||||
// voisin suivant : une liste vide ou d'un seul occupant ne forme aucune
|
|
||||||
// paire, et nul n'est apparié à lui-même.
|
|
||||||
const assis = Array.from({ length: T }, () => []);
|
const assis = Array.from({ length: T }, () => []);
|
||||||
for (let p = 0; p < N; p += 1) {
|
for (let p = 0; p < N; p += 1) {
|
||||||
const t = tableDe[p * R + r];
|
const t = tableDe[p * R + r];
|
||||||
if (t !== RESERVE) assis[t].push(p);
|
if (t !== RESERVE) assis[t].push(p);
|
||||||
}
|
}
|
||||||
|
const tables = [];
|
||||||
for (let t = 0; t < T; t += 1) {
|
for (let t = 0; t < T; t += 1) {
|
||||||
const liste = assis[t];
|
const liste = assis[t];
|
||||||
|
const collegues = [];
|
||||||
|
const revues = [];
|
||||||
|
const revenus = [];
|
||||||
for (let i = 0; i < liste.length; i += 1) {
|
for (let i = 0; i < liste.length; i += 1) {
|
||||||
const a = liste[i];
|
const a = liste[i];
|
||||||
|
if (revenu[a * R + r] === 1) revenus.push(a);
|
||||||
const aReserve = fixe[a * R + r] === t;
|
const aReserve = fixe[a * R + r] === t;
|
||||||
|
const g = groupe[a];
|
||||||
for (let j = i + 1; j < liste.length; j += 1) {
|
for (let j = i + 1; j < liste.length; j += 1) {
|
||||||
const b = liste[j];
|
const b = liste[j];
|
||||||
const k = rangPaire(N, a, b);
|
const k = rangPaire(N, a, b);
|
||||||
compte[k] += 1;
|
parPaire[k] += 1;
|
||||||
if (!aReserve || fixe[b * R + r] !== t) choisie[k] = 1;
|
if (!aReserve || fixe[b * R + r] !== t) libre[k] = 1;
|
||||||
|
if (g !== SANS_GROUPE && g === groupe[b]) collegues.push([a, b]);
|
||||||
|
if (parPaire[k] >= 2 && libre[k] === 1) revues.push([a, b]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tables.push({ collegues, revues, revenus });
|
||||||
}
|
}
|
||||||
|
parTour.push(tables);
|
||||||
}
|
}
|
||||||
return { compte, choisie };
|
return { parTour, parPaire, toursAssis, retours };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lecture du triangle des paires, dans l'ordre de rangPaire. Par personne :
|
// Lecture du triangle des paires, dans l'ordre de rangPaire. Par personne :
|
||||||
// |met(p)|, |F(p)| et A(p), vu[p * G + g] notant que p a déjà croisé un
|
// |met(p)|, |F(p)| et A(p), vu[p * G + g] notant que p a déjà croisé un
|
||||||
// affilié du groupe g. Par groupe : une paire de collègues réunie n fois
|
// affilié du groupe g. Sur toutes les paires : les paires réunies deux fois
|
||||||
// porte n collisions cumulées et une paire distincte. Sur toutes les paires :
|
// au moins, et le plus grand compte.
|
||||||
// les rencontres répétées, choisies ou imposées, et le plus grand compte.
|
function lirePaires({ N, groupe, groupes }, parPaire) {
|
||||||
function lirePaires({ N, groupe, groupes }, { compte, choisie }) {
|
|
||||||
const G = groupes.length;
|
const G = groupes.length;
|
||||||
const rencontres = new Int32Array(N);
|
const rencontres = new Int32Array(N);
|
||||||
const affilies = new Int32Array(N);
|
const affilies = new Int32Array(N);
|
||||||
const vues = new Int32Array(N);
|
const vues = new Int32Array(N);
|
||||||
const vu = new Uint8Array(N * G);
|
const vu = new Uint8Array(N * G);
|
||||||
const collisions = new Array(G).fill(0);
|
let repetees = 0;
|
||||||
const distinctes = new Array(G).fill(0);
|
|
||||||
let choisies = 0;
|
|
||||||
let imposees = 0;
|
|
||||||
let maxRencontresPaire = 0;
|
let maxRencontresPaire = 0;
|
||||||
|
|
||||||
// p rencontre q : q compte dans met(p) ; un affilié compte aussi dans F(p),
|
// p rencontre q : q compte dans met(p) ; un affilié compte aussi dans F(p),
|
||||||
|
|
@ -140,70 +217,52 @@ function lirePaires({ N, groupe, groupes }, { compte, choisie }) {
|
||||||
let k = 0;
|
let k = 0;
|
||||||
for (let a = 0; a < N; a += 1) {
|
for (let a = 0; a < N; a += 1) {
|
||||||
for (let b = a + 1; b < N; b += 1, k += 1) {
|
for (let b = a + 1; b < N; b += 1, k += 1) {
|
||||||
const n = compte[k];
|
const n = parPaire[k];
|
||||||
if (n === 0) continue;
|
if (n === 0) continue;
|
||||||
rencontrer(a, b);
|
rencontrer(a, b);
|
||||||
rencontrer(b, a);
|
rencontrer(b, a);
|
||||||
if (n > maxRencontresPaire) maxRencontresPaire = n;
|
if (n > maxRencontresPaire) maxRencontresPaire = n;
|
||||||
if (n >= 2) {
|
if (n >= 2) repetees += 1;
|
||||||
if (choisie[k] === 1) choisies += 1;
|
|
||||||
else imposees += 1;
|
|
||||||
}
|
|
||||||
const g = groupe[a];
|
|
||||||
if (g !== SANS_GROUPE && g === groupe[b]) {
|
|
||||||
collisions[g] += n;
|
|
||||||
distinctes[g] += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return { rencontres, affilies, vues, repetees, maxRencontresPaire };
|
||||||
rencontres,
|
|
||||||
affilies,
|
|
||||||
vues,
|
|
||||||
collisions,
|
|
||||||
distinctes,
|
|
||||||
rencontresRepetees: { choisies, imposees },
|
|
||||||
maxRencontresPaire,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tours assis et retours de chaque personne (§ 5.4). Pour une table visitée
|
// Lecture des listes de parcourirTours. Une paire de collègues réunie n fois
|
||||||
// m fois, dont f à un tour où la personne y est réservée : max(0, f − 1)
|
// porte n collisions cumulées et compte une fois parmi les paires
|
||||||
// retours imposés et (m − 1) − imposés retours choisis. visites et reservees
|
// distinctes de son groupe ; une paire revue compte une fois parmi les
|
||||||
// comptent par table les tours de la personne en cours ; le second parcours
|
// rencontres répétées choisies, à quelque tour qu'elle le soit ; chaque
|
||||||
// de ses tours solde chaque table à sa première occurrence et remet ses
|
// occurrence d'une personne revenue est un retour choisi.
|
||||||
// compteurs à zéro, si bien que la personne suivante les trouve nuls.
|
function lireTours({ N, groupe, groupes }, parTour) {
|
||||||
function compterRetours({ N, T, R, fixe }, tableDe) {
|
const nombrePaires = (N * (N - 1)) / 2;
|
||||||
const toursAssis = [];
|
const collisions = new Array(groupes.length).fill(0);
|
||||||
const retoursChoisis = [];
|
const distinctes = new Array(groupes.length).fill(0);
|
||||||
const retoursImposes = [];
|
const collegueVu = new Uint8Array(nombrePaires);
|
||||||
const visites = new Int32Array(T);
|
const revueVue = new Uint8Array(nombrePaires);
|
||||||
const reservees = new Int32Array(T);
|
const retoursChoisis = new Array(N).fill(0);
|
||||||
for (let p = 0; p < N; p += 1) {
|
let choisies = 0;
|
||||||
let assis = 0;
|
for (const tables of parTour) {
|
||||||
for (let r = 0; r < R; r += 1) {
|
for (const { collegues, revues, revenus } of tables) {
|
||||||
const t = tableDe[p * R + r];
|
for (const [a, b] of collegues) {
|
||||||
if (t === RESERVE) continue;
|
const g = groupe[a];
|
||||||
assis += 1;
|
const k = rangPaire(N, a, b);
|
||||||
visites[t] += 1;
|
collisions[g] += 1;
|
||||||
if (fixe[p * R + r] === t) reservees[t] += 1;
|
if (collegueVu[k] === 0) {
|
||||||
|
collegueVu[k] = 1;
|
||||||
|
distinctes[g] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [a, b] of revues) {
|
||||||
|
const k = rangPaire(N, a, b);
|
||||||
|
if (revueVue[k] === 0) {
|
||||||
|
revueVue[k] = 1;
|
||||||
|
choisies += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const p of revenus) retoursChoisis[p] += 1;
|
||||||
}
|
}
|
||||||
let choisis = 0;
|
|
||||||
let imposes = 0;
|
|
||||||
for (let r = 0; r < R; r += 1) {
|
|
||||||
const t = tableDe[p * R + r];
|
|
||||||
if (t === RESERVE || visites[t] === 0) continue;
|
|
||||||
const imposesTable = Math.max(0, reservees[t] - 1);
|
|
||||||
imposes += imposesTable;
|
|
||||||
choisis += visites[t] - 1 - imposesTable;
|
|
||||||
visites[t] = 0;
|
|
||||||
reservees[t] = 0;
|
|
||||||
}
|
|
||||||
toursAssis.push(assis);
|
|
||||||
retoursChoisis.push(choisis);
|
|
||||||
retoursImposes.push(imposes);
|
|
||||||
}
|
}
|
||||||
return { toursAssis, retoursChoisis, retoursImposes };
|
return { collisions, distinctes, choisies, retoursChoisis };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Population sans membre. min reste null tant qu'aucune valeur n'y entre :
|
// Population sans membre. min reste null tant qu'aucune valeur n'y entre :
|
||||||
|
|
@ -224,10 +283,14 @@ const conclure = ({ min, somme, effectif }) => ({
|
||||||
effectif,
|
effectif,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// La population, une valeur de plus quand elle en est une : null n'y entre
|
||||||
|
// pas, et la personne qui le porte sort de l'effectif.
|
||||||
|
const ajouterSiDefinie = (cumul, valeur) => (valeur === null ? cumul : ajouter(cumul, valeur));
|
||||||
|
|
||||||
// Minimum, moyenne et effectif de valeurs[p] sur les trois populations du
|
// Minimum, moyenne et effectif de valeurs[p] sur les trois populations du
|
||||||
// § 5.4, que replierParPopulation forme.
|
// § 5.4, que replierParPopulation forme ; replier y range chaque valeur.
|
||||||
function troisAgregats(valeurs, statut) {
|
function troisAgregats(valeurs, statut, replier = ajouter) {
|
||||||
const { tous, mobiles, ancres } = replierParPopulation(valeurs, statut, VIDE, ajouter);
|
const { tous, mobiles, ancres } = replierParPopulation(valeurs, statut, VIDE, replier);
|
||||||
return { tous: conclure(tous), mobiles: conclure(mobiles), ancres: conclure(ancres) };
|
return { tous: conclure(tous), mobiles: conclure(mobiles), ancres: conclure(ancres) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -245,16 +308,17 @@ const somme = (valeurs) => valeurs.reduce((total, valeur) => total + valeur, 0);
|
||||||
* @returns {Mesures}
|
* @returns {Mesures}
|
||||||
*/
|
*/
|
||||||
export function mesurer(instance, tableDe) {
|
export function mesurer(instance, tableDe) {
|
||||||
exigerPlanIndexe(instance, tableDe);
|
|
||||||
const { N, groupe, groupes, statut } = instance;
|
const { N, groupe, groupes, statut } = instance;
|
||||||
const paires = lirePaires(instance, compterRencontres(instance, tableDe));
|
const { parTour, parPaire, toursAssis, retours } = parcourirTours(instance, tableDe);
|
||||||
const { collisions, distinctes } = paires;
|
const paires = lirePaires(instance, parPaire);
|
||||||
|
const { collisions, distinctes, choisies, retoursChoisis } = lireTours(instance, parTour);
|
||||||
|
|
||||||
const rencontres = Array.from(paires.rencontres);
|
const rencontres = Array.from(paires.rencontres);
|
||||||
const affilies = Array.from(paires.affilies);
|
const affilies = Array.from(paires.affilies);
|
||||||
const appartenancesVues = Array.from(paires.vues);
|
const appartenancesVues = Array.from(paires.vues);
|
||||||
const redondance = affilies.map((f, p) => f - appartenancesVues[p]);
|
const redondance = affilies.map((f, p) => f - appartenancesVues[p]);
|
||||||
const diversite = affilies.map((f, p) => (f === 0 ? null : appartenancesVues[p] / f));
|
const diversite = affilies.map((f, p) => (f === 0 ? null : appartenancesVues[p] / f));
|
||||||
|
const retoursImposes = retours.map((total, p) => total - retoursChoisis[p]);
|
||||||
|
|
||||||
const effectifs = new Array(groupes.length).fill(0);
|
const effectifs = new Array(groupes.length).fill(0);
|
||||||
for (let p = 0; p < N; p += 1) {
|
for (let p = 0; p < N; p += 1) {
|
||||||
|
|
@ -270,8 +334,6 @@ export function mesurer(instance, tableDe) {
|
||||||
const collisionsCumulees = somme(collisions);
|
const collisionsCumulees = somme(collisions);
|
||||||
const pairesDistinctes = somme(distinctes);
|
const pairesDistinctes = somme(distinctes);
|
||||||
|
|
||||||
const { toursAssis, retoursChoisis, retoursImposes } = compterRetours(instance, tableDe);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rencontres,
|
rencontres,
|
||||||
affilies,
|
affilies,
|
||||||
|
|
@ -283,11 +345,13 @@ export function mesurer(instance, tableDe) {
|
||||||
retoursImposes,
|
retoursImposes,
|
||||||
aggRencontres: troisAgregats(rencontres, statut),
|
aggRencontres: troisAgregats(rencontres, statut),
|
||||||
aggRedondance: troisAgregats(redondance, statut),
|
aggRedondance: troisAgregats(redondance, statut),
|
||||||
|
aggAppartenancesVues: troisAgregats(appartenancesVues, statut),
|
||||||
|
aggDiversite: troisAgregats(diversite, statut, ajouterSiDefinie),
|
||||||
totalRedondance: somme(redondance),
|
totalRedondance: somme(redondance),
|
||||||
collisionsCumulees,
|
collisionsCumulees,
|
||||||
pairesDistinctes,
|
pairesDistinctes,
|
||||||
excedentCollisions: collisionsCumulees - pairesDistinctes,
|
excedentCollisions: collisionsCumulees - pairesDistinctes,
|
||||||
rencontresRepetees: paires.rencontresRepetees,
|
rencontresRepetees: { choisies, imposees: paires.repetees - choisies },
|
||||||
maxRencontresPaire: paires.maxRencontresPaire,
|
maxRencontresPaire: paires.maxRencontresPaire,
|
||||||
totalRetoursChoisis: somme(retoursChoisis),
|
totalRetoursChoisis: somme(retoursChoisis),
|
||||||
totalRetoursImposes: somme(retoursImposes),
|
totalRetoursImposes: somme(retoursImposes),
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { describe, test } from '../../test/lanceur.js';
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
import { indexerPlan, normaliser } from './configuration.js';
|
import { indexerPlan, normaliser } from './configuration.js';
|
||||||
import { mesurer } from './indicateurs.js';
|
import { mesurer, parcourirTours } from './indicateurs.js';
|
||||||
|
|
||||||
const SANS_CONTRAINTE = {
|
const SANS_CONTRAINTE = {
|
||||||
separerAppartenances: false,
|
separerAppartenances: false,
|
||||||
|
|
@ -135,6 +135,18 @@ describe('mesurer', () => {
|
||||||
mobiles: { min: 6, moyenne: 6, effectif: 12 },
|
mobiles: { min: 6, moyenne: 6, effectif: 12 },
|
||||||
ancres: AUCUN,
|
ancres: AUCUN,
|
||||||
},
|
},
|
||||||
|
// Chacun croise deux appartenances, la sienne exclue : A(p) = 2 pour
|
||||||
|
// huit affiliés rencontrés, d(p) = 2 / 8.
|
||||||
|
aggAppartenancesVues: {
|
||||||
|
tous: { min: 2, moyenne: 2, effectif: 12 },
|
||||||
|
mobiles: { min: 2, moyenne: 2, effectif: 12 },
|
||||||
|
ancres: AUCUN,
|
||||||
|
},
|
||||||
|
aggDiversite: {
|
||||||
|
tous: { min: 0.25, moyenne: 0.25, effectif: 12 },
|
||||||
|
mobiles: { min: 0.25, moyenne: 0.25, effectif: 12 },
|
||||||
|
ancres: AUCUN,
|
||||||
|
},
|
||||||
totalRedondance: 72,
|
totalRedondance: 72,
|
||||||
collisionsCumulees: 0,
|
collisionsCumulees: 0,
|
||||||
pairesDistinctes: 0,
|
pairesDistinctes: 0,
|
||||||
|
|
@ -268,6 +280,46 @@ describe('mesurer', () => {
|
||||||
mobiles: { min: 0, moyenne: 0, effectif: 3 },
|
mobiles: { min: 0, moyenne: 0, effectif: 3 },
|
||||||
ancres: AUCUN,
|
ancres: AUCUN,
|
||||||
});
|
});
|
||||||
|
// A(p) se replie sur chacun ; d(p) sur les seuls qui rencontrent un
|
||||||
|
// affilié : 3 sort de l'effectif, et son null n'entre ni dans le minimum
|
||||||
|
// ni dans la moyenne.
|
||||||
|
assert.deepEqual(mesures.aggAppartenancesVues, {
|
||||||
|
tous: { min: 0, moyenne: 2 / 3, effectif: 3 },
|
||||||
|
mobiles: { min: 0, moyenne: 2 / 3, effectif: 3 },
|
||||||
|
ancres: AUCUN,
|
||||||
|
});
|
||||||
|
assert.deepEqual(mesures.aggDiversite, {
|
||||||
|
tous: { min: 1, moyenne: 1, effectif: 2 },
|
||||||
|
mobiles: { min: 1, moyenne: 1, effectif: 2 },
|
||||||
|
ancres: AUCUN,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le taux de diversité se replie par population : des mobiles qui ne croisent aucun affilié laissent leur ligne vide", () => {
|
||||||
|
// 1 et 2, sans appartenance, sont ancrés à la table 1 ; 3 et 4, de X et
|
||||||
|
// de Y, mobiles, la visitent tour à tour. 1 et 2 rencontrent chacun 3 et
|
||||||
|
// 4 : d = 2 / 2. 3 rencontre 1 et 2, sans appartenance, au tour 1, puis
|
||||||
|
// personne : il sort de l'effectif ; de même 4.
|
||||||
|
const mesures = mesurerPlan({
|
||||||
|
appartenance: [null, null, 'X', 'Y'],
|
||||||
|
capacite: 3,
|
||||||
|
tours: [
|
||||||
|
[[1, 2, 3], [4]],
|
||||||
|
[[1, 2, 4], [3]],
|
||||||
|
],
|
||||||
|
reservations: [tous(1, 1), tous(2, 1)],
|
||||||
|
});
|
||||||
|
assert.deepEqual(mesures.diversite, [1, 1, null, null]);
|
||||||
|
assert.deepEqual(mesures.aggDiversite, {
|
||||||
|
tous: { min: 1, moyenne: 1, effectif: 2 },
|
||||||
|
mobiles: AUCUN,
|
||||||
|
ancres: { min: 1, moyenne: 1, effectif: 2 },
|
||||||
|
});
|
||||||
|
assert.deepEqual(mesures.aggAppartenancesVues, {
|
||||||
|
tous: { min: 0, moyenne: 1, effectif: 4 },
|
||||||
|
mobiles: { min: 0, moyenne: 0, effectif: 2 },
|
||||||
|
ancres: { min: 2, moyenne: 2, effectif: 2 },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("la réserve n'est pas une table : seuls les tours assis comptent", () => {
|
test("la réserve n'est pas une table : seuls les tours assis comptent", () => {
|
||||||
|
|
@ -521,6 +573,8 @@ describe('mesurer', () => {
|
||||||
retoursImposes: [],
|
retoursImposes: [],
|
||||||
aggRencontres: { tous: AUCUN, mobiles: AUCUN, ancres: AUCUN },
|
aggRencontres: { tous: AUCUN, mobiles: AUCUN, ancres: AUCUN },
|
||||||
aggRedondance: { tous: AUCUN, mobiles: AUCUN, ancres: AUCUN },
|
aggRedondance: { tous: AUCUN, mobiles: AUCUN, ancres: AUCUN },
|
||||||
|
aggAppartenancesVues: { tous: AUCUN, mobiles: AUCUN, ancres: AUCUN },
|
||||||
|
aggDiversite: { tous: AUCUN, mobiles: AUCUN, ancres: AUCUN },
|
||||||
totalRedondance: 0,
|
totalRedondance: 0,
|
||||||
collisionsCumulees: 0,
|
collisionsCumulees: 0,
|
||||||
pairesDistinctes: 0,
|
pairesDistinctes: 0,
|
||||||
|
|
@ -577,3 +631,84 @@ describe('mesurer', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Quatre personnes, 1 et 2 de X, deux tables de 3, trois tours ; 4 attend en
|
||||||
|
// réserve au tour 3. Les paires se rangent par rangPaire : (0, 1), (0, 2),
|
||||||
|
// (0, 3), (1, 2), (1, 3), (2, 3), en index de personnes.
|
||||||
|
const PARCOURS = {
|
||||||
|
appartenance: ['X', 'X', null, null],
|
||||||
|
capacite: 3,
|
||||||
|
tours: [
|
||||||
|
[[1, 2, 3], [4]],
|
||||||
|
[[1, 2], [3, 4]],
|
||||||
|
[[1, 3], [2]],
|
||||||
|
],
|
||||||
|
reserves: [[], [], [4]],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('parcourirTours', () => {
|
||||||
|
test('chaque table de chaque tour : collègues, paires revues, personnes revenues, en index croissants', () => {
|
||||||
|
// Tour 1 : (0, 1) collègues, rien de revu, personne ne revient. Tour 2 :
|
||||||
|
// (0, 1) collègues et revus ; 0 et 1 reviennent à la table d'index 0, 3
|
||||||
|
// à celle d'index 1, que 2 découvre. Tour 3 : (0, 2) revus, réunis
|
||||||
|
// librement au tour 1 ; 0 revient une deuxième fois, 2 revient ; 1
|
||||||
|
// découvre la table d'index 1.
|
||||||
|
const { instance, tableDe } = preparer(PARCOURS);
|
||||||
|
const vide = { collegues: [], revues: [], revenus: [] };
|
||||||
|
assert.deepEqual(parcourirTours(instance, tableDe), {
|
||||||
|
parTour: [
|
||||||
|
[{ collegues: [[0, 1]], revues: [], revenus: [] }, vide],
|
||||||
|
[
|
||||||
|
{ collegues: [[0, 1]], revues: [[0, 1]], revenus: [0, 1] },
|
||||||
|
{ collegues: [], revues: [], revenus: [3] },
|
||||||
|
],
|
||||||
|
[{ collegues: [], revues: [[0, 2]], revenus: [0, 2] }, vide],
|
||||||
|
],
|
||||||
|
parPaire: Uint16Array.of(2, 2, 0, 1, 0, 1),
|
||||||
|
toursAssis: [3, 3, 3, 2],
|
||||||
|
retours: [2, 1, 1, 1],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une rencontre et une visite que les réservations imposent ne se signalent pas ; elles restent comptées', () => {
|
||||||
|
// 1 et 2 sont réservés à la table 1 aux tours 1 et 2 : leur rencontre du
|
||||||
|
// tour 2 ne répète qu'une rencontre réservée, et chacun y revient
|
||||||
|
// réservé, comme à sa visite du tour 1. Au tour 3, 1 y revient sans
|
||||||
|
// réservation : il est revenu. parPaire et retours comptent tout,
|
||||||
|
// imposé compris.
|
||||||
|
const { instance, tableDe } = preparer({
|
||||||
|
...PARCOURS,
|
||||||
|
reservations: [auTour(1, 1, 1), auTour(2, 1, 1), auTour(1, 1, 2), auTour(2, 1, 2)],
|
||||||
|
});
|
||||||
|
const { parTour, parPaire, retours } = parcourirTours(instance, tableDe);
|
||||||
|
assert.deepEqual(parTour[1][0], { collegues: [[0, 1]], revues: [], revenus: [] });
|
||||||
|
assert.deepEqual(parTour[2][0], { collegues: [], revues: [[0, 2]], revenus: [0, 2] });
|
||||||
|
assert.deepEqual(Array.from(parPaire), [2, 2, 0, 1, 0, 1]);
|
||||||
|
assert.deepEqual(retours, [2, 1, 1, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une table, ses tours, et aucune personne présente : chaque case est vide', () => {
|
||||||
|
const instance = normaliser({
|
||||||
|
participants: [{ id: 1, nom: 'P1', appartenance: 'X', exclu: true }],
|
||||||
|
tables: [{ id: 1, numero: 1, capacite: 2 }],
|
||||||
|
tours: 2,
|
||||||
|
reservations: [],
|
||||||
|
contraintes: SANS_CONTRAINTE,
|
||||||
|
});
|
||||||
|
const vide = { collegues: [], revues: [], revenus: [] };
|
||||||
|
assert.deepEqual(parcourirTours(instance, new Int32Array(0)), {
|
||||||
|
parTour: [[vide], [vide]],
|
||||||
|
parPaire: new Uint16Array(0),
|
||||||
|
toursAssis: [],
|
||||||
|
retours: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lève ce que lève exigerPlanIndexe, et ne modifie ni l'instance ni le plan", () => {
|
||||||
|
const { instance, tableDe } = preparer(PARCOURS);
|
||||||
|
assert.throws(() => parcourirTours(instance, tableDe.subarray(1)), RangeError);
|
||||||
|
const avant = { tableDe: tableDe.slice(), fixe: instance.fixe.slice(), groupe: instance.groupe.slice() };
|
||||||
|
parcourirTours(instance, tableDe);
|
||||||
|
assert.deepEqual({ tableDe, fixe: instance.fixe, groupe: instance.groupe }, avant);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,9 @@
|
||||||
// d'une proposition, la place d'une autre à une autre table touche les deux
|
// d'une proposition, la place d'une autre à une autre table touche les deux
|
||||||
// listes de ces tables et rien d'autre : une entrée de chacune quand chaque
|
// listes de ces tables et rien d'autre : une entrée de chacune quand chaque
|
||||||
// personne prend le rang de l'autre, ce que garde l'ordre des sièges quand
|
// personne prend le rang de l'autre, ce que garde l'ordre des sièges quand
|
||||||
// ils sont attribués. Sans attribution, chaque liste se trie par
|
// ils sont attribués ; une personne qui quitte sa chaise sans être
|
||||||
|
// remplacée y laisse null, le marqueur d'une chaise vide, une pose au rang
|
||||||
|
// de la chaise. Sans attribution, chaque liste se trie par
|
||||||
// identifiant, et l'entrée qui change de rang décale celles qu'elle
|
// identifiant, et l'entrée qui change de rang décale celles qu'elle
|
||||||
// franchit ; une liste d'identifiants de même longueur se pose alors
|
// franchit ; une liste d'identifiants de même longueur se pose alors
|
||||||
// entière dès que ses poses rang par rang pèsent plus que cette pose, et
|
// entière dès que ses poses rang par rang pèsent plus que cette pose, et
|
||||||
|
|
@ -64,12 +66,15 @@ import { ErreurStockage } from './erreurs.js';
|
||||||
* @typedef {OperationPoser|OperationRetirer} Operation
|
* @typedef {OperationPoser|OperationRetirer} Operation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Règles de la charge, du retenu et d'un identifiant dans le schéma du
|
// Règles de la charge, du retenu, d'un identifiant et de l'occupant d'une
|
||||||
// fichier d'état.
|
// liste de table — un identifiant, ou null pour une chaise vide — dans le
|
||||||
|
// schéma du fichier d'état.
|
||||||
const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
|
const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
|
||||||
const CHARGE = regleDuChamp(SCHEMA, 'charge');
|
const CHARGE = regleDuChamp(SCHEMA, 'charge');
|
||||||
const RETENU = regleDuChamp(CHARGE, 'retenu');
|
const RETENU = regleDuChamp(CHARGE, 'retenu');
|
||||||
const IDENTIFIANT = regleDuChamp(regleDuChamp(CHARGE, 'propositions').element, 'id');
|
const PROPOSITION = regleDuChamp(CHARGE, 'propositions').element;
|
||||||
|
const IDENTIFIANT = regleDuChamp(PROPOSITION, 'id');
|
||||||
|
const OCCUPANT = regleDuChamp(regleDuChamp(PROPOSITION, 'placement').element, 'sieges').element.element;
|
||||||
|
|
||||||
// Vrai pour un objet qui n'est ni null ni une liste.
|
// Vrai pour un objet qui n'est ni null ni une liste.
|
||||||
const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
|
const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
|
||||||
|
|
@ -103,9 +108,9 @@ const poids = (operations) => JSON.stringify(operations).length;
|
||||||
// objets se comparent champ par champ, dans l'ordre du schéma. Deux listes
|
// objets se comparent champ par champ, dans l'ordre du schéma. Deux listes
|
||||||
// d'enregistrements à identifiants distincts se comparent par identifiant ;
|
// d'enregistrements à identifiants distincts se comparent par identifiant ;
|
||||||
// deux autres listes, de longueurs différentes, posent b entière, et de même
|
// deux autres listes, de longueurs différentes, posent b entière, et de même
|
||||||
// longueur se comparent rang par rang — deux listes d'identifiants
|
// longueur se comparent rang par rang — deux listes d'identifiants ou
|
||||||
// exceptées, qui posent b entière quand leurs poses rang par rang pèsent
|
// d'occupants exceptées, qui posent b entière quand leurs poses rang par
|
||||||
// plus que la sienne.
|
// rang pèsent plus que la sienne.
|
||||||
function comparer(a, b, regle, chemin, operations) {
|
function comparer(a, b, regle, chemin, operations) {
|
||||||
if (a === null || b === null || (regle.genre !== 'objet' && regle.genre !== 'liste')) {
|
if (a === null || b === null || (regle.genre !== 'objet' && regle.genre !== 'liste')) {
|
||||||
if (a !== b) operations.push({ op: 'poser', chemin, valeur: b });
|
if (a !== b) operations.push({ op: 'poser', chemin, valeur: b });
|
||||||
|
|
@ -117,23 +122,25 @@ function comparer(a, b, regle, chemin, operations) {
|
||||||
comparerParIdentifiant(a, b, regle.element, chemin, operations);
|
comparerParIdentifiant(a, b, regle.element, chemin, operations);
|
||||||
} else if (a.length !== b.length) {
|
} else if (a.length !== b.length) {
|
||||||
operations.push({ op: 'poser', chemin, valeur: b });
|
operations.push({ op: 'poser', chemin, valeur: b });
|
||||||
} else if (regle.element === IDENTIFIANT) {
|
} else if (regle.element === IDENTIFIANT || regle.element === OCCUPANT) {
|
||||||
comparerIdentifiants(a, b, chemin, operations);
|
comparerIdentifiants(a, b, regle.element, chemin, operations);
|
||||||
} else {
|
} else {
|
||||||
a.forEach((element, rang) => comparer(element, b[rang], regle.element, [...chemin, rang], operations));
|
a.forEach((element, rang) => comparer(element, b[rang], regle.element, [...chemin, rang], operations));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ajoute à operations ce qui mène de a à b, deux listes d'identifiants de
|
// Ajoute à operations ce qui mène de a à b, deux listes d'identifiants de
|
||||||
// même longueur, au bout de chemin : une pose par rang qui change, ou b
|
// même longueur, d'élément de règle regleElement, au bout de chemin : une
|
||||||
// entière quand ces poses pèsent plus que la sienne. Une liste triée par
|
// pose par rang qui change, ou b entière quand ces poses pèsent plus que la
|
||||||
// identifiant — une liste de table sans attribution, une réserve, les
|
// sienne. Une liste triée par identifiant — une liste de table sans
|
||||||
// participants d'un plan — décale d'un rang chaque entrée qu'un identifiant
|
// attribution, une réserve, les participants d'un plan — décale d'un rang
|
||||||
// remplacé franchit, et ses poses rang par rang se multiplient ; ce qui
|
// chaque entrée qu'un identifiant remplacé franchit, et ses poses rang par
|
||||||
// s'en écrit ne pèse jamais plus que la liste posée entière.
|
// rang se multiplient ; ce qui s'en écrit ne pèse jamais plus que la liste
|
||||||
function comparerIdentifiants(a, b, chemin, operations) {
|
// posée entière. Sous des sièges attribués, une chaise libérée ou reprise
|
||||||
|
// ne change que son rang : une pose, de valeur null ou de l'occupant.
|
||||||
|
function comparerIdentifiants(a, b, regleElement, chemin, operations) {
|
||||||
const parRang = [];
|
const parRang = [];
|
||||||
a.forEach((id, rang) => comparer(id, b[rang], IDENTIFIANT, [...chemin, rang], parRang));
|
a.forEach((id, rang) => comparer(id, b[rang], regleElement, [...chemin, rang], parRang));
|
||||||
const entiere = { op: 'poser', chemin, valeur: b };
|
const entiere = { op: 'poser', chemin, valeur: b };
|
||||||
operations.push(...(poids(parRang) > poids([entiere]) ? [entiere] : parRang));
|
operations.push(...(poids(parRang) > poids([entiere]) ? [entiere] : parRang));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1662,3 +1662,90 @@ describe("appliquer : un correctif qui ne s'applique pas lève CORRECTIF avec le
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('le marqueur de chaise vide : correctifs et forme canonique (§ 8.9)', () => {
|
||||||
|
// La petite démonstration, sa proposition 1 à sièges attribués, retenue
|
||||||
|
// telle quelle : au tour 1, la table 1 assied 9, 5 et 1, dans cet ordre
|
||||||
|
// de sièges. Une charge neuve à chaque appel.
|
||||||
|
function retenuAttribue() {
|
||||||
|
const charge = chargeDe('petite', [{ id: 1, siegesAttribues: true }]);
|
||||||
|
charge.propositions[0].placement[0].sieges[0] = [9, 5, 1];
|
||||||
|
charge.retenu = retenuDe(charge.propositions[0]);
|
||||||
|
charge.evenement.etat = 'retenu';
|
||||||
|
return charge;
|
||||||
|
}
|
||||||
|
|
||||||
|
// retenuAttribue, la personne 5 retirée du tour 1 du retenu : sa chaise,
|
||||||
|
// le siège 2 de la table 1, est libre, et elle attend en réserve.
|
||||||
|
function chaiseLiberee() {
|
||||||
|
const charge = retenuAttribue();
|
||||||
|
charge.retenu.placement[0].sieges[0] = [9, null, 1];
|
||||||
|
charge.retenu.placement[0].reserve = [5];
|
||||||
|
return charge;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("libérer une chaise du retenu : une seule pose dans la liste, valeur null, au rang de la chaise ; appliquer rend la charge d'après, que canoniser garde dans l'ordre des sièges", () => {
|
||||||
|
const a = geler(retenuAttribue());
|
||||||
|
const b = geler(chaiseLiberee());
|
||||||
|
assert.deepStrictEqual(fautes(b.retenu), []);
|
||||||
|
const correctif = difference(a, b);
|
||||||
|
assert.deepStrictEqual(correctif, [
|
||||||
|
{ op: 'poser', chemin: ['retenu', 'placement', 0, 'sieges', 0, 1], valeur: null },
|
||||||
|
{ op: 'poser', chemin: ['retenu', 'placement', 0, 'reserve'], valeur: [5] },
|
||||||
|
]);
|
||||||
|
const rendue = appliquer(a, correctif);
|
||||||
|
assert.deepStrictEqual(rendue, canoniser(b));
|
||||||
|
assert.deepStrictEqual(rendue.retenu.placement[0].sieges[0], [9, null, 1]);
|
||||||
|
assert.equal(serialiserCharge(rendue), serialiserCharge(b));
|
||||||
|
assert.deepStrictEqual(analyser(serialiser(rendue, ENTETE)).charge.retenu, rendue.retenu);
|
||||||
|
// Le retour : la chaise reprend son occupant, par une pose au même rang.
|
||||||
|
const retour = difference(b, a);
|
||||||
|
assert.deepStrictEqual(retour, [
|
||||||
|
{ op: 'poser', chemin: ['retenu', 'placement', 0, 'sieges', 0, 1], valeur: 5 },
|
||||||
|
{ op: 'poser', chemin: ['retenu', 'placement', 0, 'reserve'], valeur: [] },
|
||||||
|
]);
|
||||||
|
assert.deepStrictEqual(appliquer(b, retour), canoniser(a));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dans une proposition aussi, une chaise libérée est une pose au rang de la chaise ; une liste qui change à chaque rang se pose entière, chaises vides comprises", () => {
|
||||||
|
const a = geler(retenuAttribue());
|
||||||
|
const b = retenuAttribue();
|
||||||
|
b.propositions[0].placement[0].sieges[0] = [9, null, 1];
|
||||||
|
b.propositions[0].placement[0].reserve = [5];
|
||||||
|
assert.deepStrictEqual(difference(a, geler(b)), [
|
||||||
|
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0, 'sieges', 0, 1], valeur: null },
|
||||||
|
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0, 'reserve'], valeur: [5] },
|
||||||
|
]);
|
||||||
|
const c = chaiseLiberee();
|
||||||
|
c.retenu.placement[0].sieges[0] = [1, null, 9];
|
||||||
|
const correctif = difference(a, geler(c));
|
||||||
|
assert.deepStrictEqual(correctif, [
|
||||||
|
{ op: 'poser', chemin: ['retenu', 'placement', 0, 'sieges', 0], valeur: [1, null, 9] },
|
||||||
|
{ op: 'poser', chemin: ['retenu', 'placement', 0, 'reserve'], valeur: [5] },
|
||||||
|
]);
|
||||||
|
assert.deepStrictEqual(appliquer(a, correctif), canoniser(c));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("appliquer admet null dans une liste de table, comme la règle, et le refuse ailleurs dans un placement : réserve, tables, participants, capacités", () => {
|
||||||
|
const base = geler(chaiseLiberee());
|
||||||
|
const admises = [
|
||||||
|
['retenu', 'placement', 1, 'sieges', 2, 1],
|
||||||
|
['propositions', { id: 1 }, 'placement', 2, 'sieges', 0, 1],
|
||||||
|
];
|
||||||
|
for (const chemin of admises) {
|
||||||
|
assert.equal(refusDe(base, [{ op: 'poser', chemin, valeur: null }]), 'aucun refus', JSON.stringify(chemin));
|
||||||
|
}
|
||||||
|
const refusees = [
|
||||||
|
['retenu', 'placement', 0, 'reserve', 0],
|
||||||
|
['retenu', 'tables', 0],
|
||||||
|
['retenu', 'participants', 0],
|
||||||
|
['retenu', 'capacites', 0],
|
||||||
|
['propositions', { id: 1 }, 'placement', 1, 'sieges', 0],
|
||||||
|
['propositions', { id: 1 }, 'participants', 3],
|
||||||
|
];
|
||||||
|
assert.ok(refusees.length > 0);
|
||||||
|
for (const chemin of refusees) {
|
||||||
|
assert.deepEqual(refusDe(base, [{ op: 'poser', chemin, valeur: null }]), { rang: 0 }, JSON.stringify(chemin));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,12 @@ const NOMBRE = regle('nombre');
|
||||||
// reste ainsi loin de 2^53, où l'analyse ne le relirait plus. Au-delà, la
|
// reste ainsi loin de 2^53, où l'analyse ne le relirait plus. Au-delà, la
|
||||||
// valeur sort de sa règle.
|
// valeur sort de sa règle.
|
||||||
const IDENTIFIANT = entier(1, 2 ** 31 - 1);
|
const IDENTIFIANT = entier(1, 2 ** 31 - 1);
|
||||||
|
// L'occupant d'un siège dans une liste de table d'un placement :
|
||||||
|
// l'identifiant d'une personne, ou null, le marqueur d'une chaise vide
|
||||||
|
// (§ 8.9). La règle admet null à toute place d'une telle liste ; le
|
||||||
|
// contrôle des placements (placements.js) ne l'admet que sous des sièges
|
||||||
|
// attribués, et jamais en fin de liste.
|
||||||
|
const OCCUPANT = entier(1, 2 ** 31 - 1, { nul: true });
|
||||||
const DATE_OU_NUL = regle('date', { nul: true });
|
const DATE_OU_NUL = regle('date', { nul: true });
|
||||||
|
|
||||||
// Ordres des listes de la charge (§ 8.8, § 8.9). canonique.js range les
|
// Ordres des listes de la charge (§ 8.8, § 8.9). canonique.js range les
|
||||||
|
|
@ -196,15 +202,18 @@ const TITRE = objet([
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Le placement d'une proposition ou du retenu (§ 8.9) : un tour par élément,
|
// Le placement d'une proposition ou du retenu (§ 8.9) : un tour par élément,
|
||||||
// chacun sur sa ligne. sieges porte une liste d'identifiants par table
|
// chacun sur sa ligne. sieges porte une liste d'occupants par table
|
||||||
// déclarée, dans l'ordre des sièges. Quand l'objet qui porte le placement a
|
// déclarée, dans l'ordre des sièges. Quand l'objet qui porte le placement a
|
||||||
// siegesAttribues faux, cet ordre ne porte rien, et chaque liste se trie par
|
// siegesAttribues faux, cet ordre ne porte rien, et chaque liste se trie par
|
||||||
// identifiant croissant ; vrai, il est celui des sièges et se garde. reserve
|
// identifiant croissant ; vrai, il est celui des sièges et se garde : le
|
||||||
|
// siège est le rang dans la liste, et une chaise vide avant le dernier
|
||||||
|
// occupant, siège 1 compris, y porte null. Une liste qui ne finit jamais
|
||||||
|
// par null a ainsi une seule écriture, que le tri ne touche pas. reserve
|
||||||
// porte ceux qui ne sont assis nulle part à ce tour, un ensemble : elle se
|
// porte ceux qui ne sont assis nulle part à ce tour, un ensemble : elle se
|
||||||
// trie par identifiant croissant, quel que soit le drapeau.
|
// trie par identifiant croissant, quel que soit le drapeau.
|
||||||
const PLACEMENT = liste(
|
const PLACEMENT = liste(
|
||||||
objet([
|
objet([
|
||||||
['sieges', liste(liste(IDENTIFIANT, { triSansAttribution: croissant }))],
|
['sieges', liste(liste(OCCUPANT, { triSansAttribution: croissant }))],
|
||||||
['reserve', liste(IDENTIFIANT, { tri: croissant })],
|
['reserve', liste(IDENTIFIANT, { tri: croissant })],
|
||||||
]),
|
]),
|
||||||
{ mise: 'lignes' },
|
{ mise: 'lignes' },
|
||||||
|
|
|
||||||
|
|
@ -1026,3 +1026,48 @@ describe('premiereFaute : le parcours de forme, sans lever (§ 8.8, § 8.9)', ()
|
||||||
assert.equal(premiereFauteEnPlace({ ...evenement, nom: '' }, EVENEMENT, 'charge.evenement'), 'charge.evenement.nom');
|
assert.equal(premiereFauteEnPlace({ ...evenement, nom: '' }, EVENEMENT, 'charge.evenement'), 'charge.evenement.nom');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('le marqueur de chaise vide : la règle d\'une liste de table (§ 8.9)', () => {
|
||||||
|
// La proposition du document valide, sièges attribués, dont le tour 2
|
||||||
|
// laisse vide le siège 2 de la table 3 : sa liste vaut [1, null, 5].
|
||||||
|
const propositionAChaiseVide = () => {
|
||||||
|
const proposition = documentValide().charge.propositions[0];
|
||||||
|
proposition.siegesAttribues = true;
|
||||||
|
proposition.placement[1].sieges[1] = [1, null, 5];
|
||||||
|
return proposition;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("premiereFaute admet null dans une liste de table, d'une proposition comme du retenu, et nulle part ailleurs dans un placement", () => {
|
||||||
|
const regleDe = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
|
||||||
|
const CHARGE = regleDe(SCHEMA, 'charge');
|
||||||
|
const PROPOSITION = regleDe(CHARGE, 'propositions').element;
|
||||||
|
const RETENU = regleDe(CHARGE, 'retenu');
|
||||||
|
const proposition = propositionAChaiseVide();
|
||||||
|
const { id, graine, arret, historique, produitVersion, ...plan } = proposition;
|
||||||
|
const retenu = { proposition: id, ...plan };
|
||||||
|
assert.equal(premiereFaute(proposition, PROPOSITION), null);
|
||||||
|
assert.equal(premiereFaute(retenu, RETENU), null);
|
||||||
|
const cas = [
|
||||||
|
['dans une réserve', (p) => { p.placement[0].reserve = [null]; }, 'placement[0].reserve[0]'],
|
||||||
|
['parmi les participants', (p) => { p.participants[1] = null; }, 'participants[1]'],
|
||||||
|
['parmi les tables', (p) => { p.tables[0] = null; }, 'tables[0]'],
|
||||||
|
['parmi les capacités', (p) => { p.capacites[1] = null; }, 'capacites[1]'],
|
||||||
|
['à la place d\'une liste de table', (p) => { p.placement[0].sieges[0] = null; }, 'placement[0].sieges[0]'],
|
||||||
|
];
|
||||||
|
assert.ok(cas.length > 0);
|
||||||
|
for (const [libelle, abimer, chemin] of cas) {
|
||||||
|
const abimee = propositionAChaiseVide();
|
||||||
|
abimer(abimee);
|
||||||
|
assert.equal(premiereFaute(abimee, PROPOSITION), chemin, libelle);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("l'analyse lit un fichier dont une proposition porte une chaise vide, et sa relecture rend la liste telle qu'écrite", () => {
|
||||||
|
const documentLu = documentValide();
|
||||||
|
documentLu.charge.propositions = [propositionAChaiseVide()];
|
||||||
|
const { charge } = analyser(JSON.stringify(documentLu));
|
||||||
|
assert.deepStrictEqual(charge.propositions[0].placement[1].sieges[1], [1, null, 5]);
|
||||||
|
const relue = analyser(serialiser(charge, { revision: 13, produitVersion: VERSION.affichee })).charge;
|
||||||
|
assert.deepStrictEqual(relue.propositions[0], propositionAChaiseVide());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,10 @@
|
||||||
//
|
//
|
||||||
// L'OPFS et le fichier choisi lèvent des DOMException, que le module traduit
|
// L'OPFS et le fichier choisi lèvent des DOMException, que le module traduit
|
||||||
// en codes. Une lecture qui ne trouve pas l'entrée — NotFoundError, ou
|
// en codes. Une lecture qui ne trouve pas l'entrée — NotFoundError, ou
|
||||||
// TypeMismatchError pour une entrée d'un autre genre que celui demandé —
|
// TypeMismatchError pour une entrée d'un autre genre que celui demandé, hors
|
||||||
// lève ABSENT ; toute autre exception d'une lecture lève LECTURE, car une
|
// le cas d'un nom non converti ci-dessous — lève ABSENT ; toute autre
|
||||||
// panne n'est pas une absence ; tout échec d'une écriture lève ECRITURE. Les
|
// exception d'une lecture lève LECTURE, car une panne n'est pas une
|
||||||
|
// absence ; tout échec d'une écriture lève ECRITURE. Les
|
||||||
// deux portent {chemin, dossier, cause}, dont la cause est le nom de
|
// deux portent {chemin, dossier, cause}, dont la cause est le nom de
|
||||||
// l'exception : SecurityError pour un OPFS que le navigateur refuse à
|
// l'exception : SecurityError pour un OPFS que le navigateur refuse à
|
||||||
// l'origine, NotReadableError pour un fichier qui change pendant qu'on le
|
// l'origine, NotReadableError pour un fichier qui change pendant qu'on le
|
||||||
|
|
@ -43,6 +44,17 @@
|
||||||
// TypeMismatchError, QuotaExceededError… Le chemin '' désigne la racine
|
// TypeMismatchError, QuotaExceededError… Le chemin '' désigne la racine
|
||||||
// elle-même, un dossier.
|
// elle-même, un dossier.
|
||||||
//
|
//
|
||||||
|
// Sous des paramètres régionaux qui ne sont pas UTF-8, Chromium sous Linux
|
||||||
|
// ne convertit pas un nom hors ASCII et le résout sur le dossier qui le
|
||||||
|
// porte : un dossier de ce nom rend ce dossier sans lever, un fichier de ce
|
||||||
|
// nom lève TypeMismatchError comme pour une entrée d'un autre genre. Chaque
|
||||||
|
// dossier du chemin se compare donc au dossier qui le porte (isSameEntry) ;
|
||||||
|
// une TypeMismatchError sur un fichier se départage en demandant le même nom
|
||||||
|
// comme dossier, comparé de même. La même entrée lève, cause
|
||||||
|
// NOM_NON_CONVERTI — LECTURE pour une lecture, ECRITURE pour une écriture —,
|
||||||
|
// jamais ABSENT ni EXISTE : rien ne se lit ni ne s'écrit dans le dossier qui
|
||||||
|
// le porte, et un fichier présent ne se lit pas absent.
|
||||||
|
//
|
||||||
// Sous web, ni explorateur ni dialogue de dossier : ouvrirDansExplorateur et
|
// Sous web, ni explorateur ni dialogue de dossier : ouvrirDansExplorateur et
|
||||||
// choisirDossier lèvent NON_DISPONIBLE {primitive}. choisirFichierAImporter
|
// choisirDossier lèvent NON_DISPONIBLE {primitive}. choisirFichierAImporter
|
||||||
// passe par un champ de fichier, que le navigateur n'ouvre que dans le
|
// passe par un champ de fichier, que le navigateur n'ouvre que dans le
|
||||||
|
|
@ -69,6 +81,10 @@ const TYPE_DIFFERENT = 'TypeMismatchError';
|
||||||
// Cause d'une sonde dont le témoin relu diffère du texte écrit.
|
// Cause d'une sonde dont le témoin relu diffère du texte écrit.
|
||||||
const RELECTURE_DIFFERENTE = 'RELECTURE_DIFFERENTE';
|
const RELECTURE_DIFFERENTE = 'RELECTURE_DIFFERENTE';
|
||||||
|
|
||||||
|
// Cause d'un nom, de dossier ou de fichier, que l'OPFS résout sur le dossier
|
||||||
|
// qui le porte.
|
||||||
|
const NOM_NON_CONVERTI = 'NOM_NON_CONVERTI';
|
||||||
|
|
||||||
// Un fichier s'écrit en UTF-8 sans marque d'ordre d'octets, et se lit sans
|
// Un fichier s'écrit en UTF-8 sans marque d'ordre d'octets, et se lit sans
|
||||||
// retirer celle qu'il porte, que Blob.text retirerait. Le décodage est
|
// retirer celle qu'il porte, que Blob.text retirerait. Le décodage est
|
||||||
// strict : un octet qui n'est pas de l'UTF-8 ne se lit pas U+FFFD.
|
// strict : un octet qui n'est pas de l'UTF-8 ne se lit pas U+FFFD.
|
||||||
|
|
@ -79,7 +95,9 @@ const DECODEUR = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
||||||
const ignorer = () => {};
|
const ignorer = () => {};
|
||||||
|
|
||||||
// Vrai pour l'exception de l'OPFS qui dit qu'une entrée manque : absente, ou
|
// Vrai pour l'exception de l'OPFS qui dit qu'une entrée manque : absente, ou
|
||||||
// d'un autre genre que celui demandé.
|
// d'un autre genre que celui demandé. Une TypeMismatchError de fichier passe
|
||||||
|
// d'abord par fichierDe, qui lève NOM_NON_CONVERTI à sa place quand le nom
|
||||||
|
// n'est pas converti.
|
||||||
const estAbsence = (erreur) => erreur?.name === 'NotFoundError' || erreur?.name === TYPE_DIFFERENT;
|
const estAbsence = (erreur) => erreur?.name === 'NotFoundError' || erreur?.name === TYPE_DIFFERENT;
|
||||||
|
|
||||||
// Cause d'une ECRITURE ou d'une LECTURE : le nom de l'exception, ou son
|
// Cause d'une ECRITURE ou d'une LECTURE : le nom de l'exception, ou son
|
||||||
|
|
@ -134,17 +152,50 @@ async function ecrire(loc, travail) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Poignée du dossier nom sous parent. Lève une DOMException de nom
|
||||||
|
// NOM_NON_CONVERTI quand l'OPFS rend parent lui-même pour ce nom.
|
||||||
|
async function sousDossier(parent, nom, options) {
|
||||||
|
const dossier = await parent.getDirectoryHandle(nom, options);
|
||||||
|
if (await dossier.isSameEntry(parent)) throw new DOMException(nom, NOM_NON_CONVERTI);
|
||||||
|
return dossier;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vrai quand l'OPFS résout nom, demandé comme dossier, sur dossier lui-même ;
|
||||||
|
// faux pour toute autre poignée, ou quand la demande lève.
|
||||||
|
async function resoutSurLuiMeme(dossier, nom) {
|
||||||
|
try {
|
||||||
|
return await (await dossier.getDirectoryHandle(nom)).isSameEntry(dossier);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poignée du fichier nom du dossier. Lève l'exception de l'OPFS, sauf une
|
||||||
|
// TypeMismatchError dont le nom se résout sur le dossier lui-même, qui
|
||||||
|
// devient une DOMException de nom NOM_NON_CONVERTI.
|
||||||
|
async function fichierDe(dossier, nom, options) {
|
||||||
|
try {
|
||||||
|
return await dossier.getFileHandle(nom, options);
|
||||||
|
} catch (erreur) {
|
||||||
|
if (erreur?.name === TYPE_DIFFERENT && (await resoutSurLuiMeme(dossier, nom))) {
|
||||||
|
throw new DOMException(nom, NOM_NON_CONVERTI);
|
||||||
|
}
|
||||||
|
throw erreur;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Poignée du dossier que désignent les segments sous le dossier du produit ;
|
// Poignée du dossier que désignent les segments sous le dossier du produit ;
|
||||||
// creer crée ce qui manque sous lui. Le dossier du produit lui-même ne se
|
// creer crée ce qui manque sous lui. Le dossier du produit lui-même ne se
|
||||||
// crée que pour des segments vides, qui le désignent : un chemin sous un
|
// crée que pour des segments vides, qui le désignent : un chemin sous un
|
||||||
// dossier du produit absent lève NotFoundError (§ 8.6). Lève l'exception de
|
// dossier du produit absent lève NotFoundError (§ 8.6). Lève l'exception de
|
||||||
// l'OPFS : NotFoundError pour un dossier absent, TypeMismatchError pour un
|
// l'OPFS : NotFoundError pour un dossier absent, TypeMismatchError pour un
|
||||||
// fichier à sa place.
|
// fichier à sa place ; NOM_NON_CONVERTI pour un dossier résolu sur celui qui
|
||||||
|
// le porte.
|
||||||
async function dossierDe(segments, creer = false) {
|
async function dossierDe(segments, creer = false) {
|
||||||
const options = { create: creer };
|
const options = { create: creer };
|
||||||
const produit = { create: creer && segments.length === 0 };
|
const produit = { create: creer && segments.length === 0 };
|
||||||
let dossier = await (await navigator.storage.getDirectory()).getDirectoryHandle(DOSSIER_PRODUIT, produit);
|
let dossier = await sousDossier(await navigator.storage.getDirectory(), DOSSIER_PRODUIT, produit);
|
||||||
for (const segment of segments) dossier = await dossier.getDirectoryHandle(segment, options);
|
for (const segment of segments) dossier = await sousDossier(dossier, segment, options);
|
||||||
return dossier;
|
return dossier;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,10 +210,10 @@ async function dossierExistant(segments) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Genre de l'entrée nom du dossier : 'fichier', 'dossier', ou null quand elle
|
// Genre de l'entrée nom du dossier : 'fichier', 'dossier', ou null quand elle
|
||||||
// n'existe pas.
|
// n'existe pas. Un nom non converti lève NOM_NON_CONVERTI.
|
||||||
async function genreDe(dossier, nom) {
|
async function genreDe(dossier, nom) {
|
||||||
try {
|
try {
|
||||||
await dossier.getFileHandle(nom);
|
await fichierDe(dossier, nom);
|
||||||
return 'fichier';
|
return 'fichier';
|
||||||
} catch (erreur) {
|
} catch (erreur) {
|
||||||
if (erreur?.name === TYPE_DIFFERENT) return 'dossier';
|
if (erreur?.name === TYPE_DIFFERENT) return 'dossier';
|
||||||
|
|
@ -200,7 +251,7 @@ async function ecrireParFlux(dossier, nom, garder, remplir) {
|
||||||
let poignee;
|
let poignee;
|
||||||
let cree = false;
|
let cree = false;
|
||||||
try {
|
try {
|
||||||
poignee = await dossier.getFileHandle(nom);
|
poignee = await fichierDe(dossier, nom);
|
||||||
} catch (erreur) {
|
} catch (erreur) {
|
||||||
if (erreur?.name !== 'NotFoundError') throw erreur;
|
if (erreur?.name !== 'NotFoundError') throw erreur;
|
||||||
poignee = await dossier.getFileHandle(nom, { create: true });
|
poignee = await dossier.getFileHandle(nom, { create: true });
|
||||||
|
|
@ -235,7 +286,8 @@ async function decrire(poignee) {
|
||||||
const parNom = (a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : 0);
|
const parNom = (a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : 0);
|
||||||
|
|
||||||
// La source de deplacer : le dossier qui la porte et ses octets. ABSENT quand
|
// La source de deplacer : le dossier qui la porte et ses octets. ABSENT quand
|
||||||
// elle n'existe pas ; ECRITURE TypeMismatchError quand c'est un dossier.
|
// elle n'existe pas ; ECRITURE TypeMismatchError quand c'est un dossier ;
|
||||||
|
// NOM_NON_CONVERTI, que genreDe lève, avant toute lecture de ses octets.
|
||||||
async function lireSource(loc) {
|
async function lireSource(loc) {
|
||||||
if (loc.nom === null) throw refus(loc, TYPE_DIFFERENT);
|
if (loc.nom === null) throw refus(loc, TYPE_DIFFERENT);
|
||||||
const dossier = await dossierExistant(loc.parent);
|
const dossier = await dossierExistant(loc.parent);
|
||||||
|
|
@ -338,7 +390,7 @@ export function creerFichiersWeb() {
|
||||||
if (loc.nom === null) throw absent(chemin);
|
if (loc.nom === null) throw absent(chemin);
|
||||||
let lu;
|
let lu;
|
||||||
try {
|
try {
|
||||||
lu = await lire(await (await dossierDe(loc.parent)).getFileHandle(loc.nom));
|
lu = await lire(await fichierDe(await dossierDe(loc.parent), loc.nom));
|
||||||
} catch (erreur) {
|
} catch (erreur) {
|
||||||
throw echecLecture(loc, erreur);
|
throw echecLecture(loc, erreur);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,27 @@ describe('fichiers web : la plateforme et sa racine (§ 8.8, § 13.1)', () => {
|
||||||
expect(await fs.typeSupport(DOCUMENTS)).toBe('inconnu');
|
expect(await fs.typeSupport(DOCUMENTS)).toBe('inconnu');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sous Linux, Chromium convertit le nom d'une entrée de l'OPFS par les
|
||||||
|
// paramètres régionaux de son processus. Hors UTF-8 — LANG absent,
|
||||||
|
// LC_ALL=C —, un nom hors ASCII ne se convertit pas et désigne le dossier
|
||||||
|
// qui le porte : un fichier y lève TypeMismatchError, un dossier y rend son
|
||||||
|
// parent. Les épreuves qui écrivent un nom accentué n'éprouvent alors plus
|
||||||
|
// le module ; celle-ci, sur l'OPFS seul, nomme la cause.
|
||||||
|
test("l'OPFS de ce Chromium nomme une entrée hors ASCII : un fichier et un dossier accentués se créent et se listent sous leur nom", async () => {
|
||||||
|
const racine = await opfs();
|
||||||
|
const cause = 'Chromium tourne sous des paramètres régionaux qui ne sont pas UTF-8 : lancer les épreuves sous LANG=C.UTF-8';
|
||||||
|
let refus = null;
|
||||||
|
try {
|
||||||
|
await racine.getFileHandle('\u{E9}t\u{E9}.txt', { create: true });
|
||||||
|
} catch (erreur) {
|
||||||
|
refus = erreur.name;
|
||||||
|
}
|
||||||
|
expect(refus, cause).toBeNull();
|
||||||
|
const dossier = await racine.getDirectoryHandle('\u{E9}', { create: true });
|
||||||
|
expect(await dossier.isSameEntry(racine), cause).toBe(false);
|
||||||
|
expect(await nomsOpfs(), cause).toStrictEqual(['\u{E9}', '\u{E9}t\u{E9}.txt']);
|
||||||
|
});
|
||||||
|
|
||||||
test("la racine documents est le dossier gestion_table_tournante_libre de l'OPFS : ce qui s'y écrit s'y lit en UTF-8 sans marque d'ordre d'octets, et ce qui s'y pose se lit et se liste", async () => {
|
test("la racine documents est le dossier gestion_table_tournante_libre de l'OPFS : ce qui s'y écrit s'y lit en UTF-8 sans marque d'ordre d'octets, et ce qui s'y pose se lit et se liste", async () => {
|
||||||
const fs = await preparer();
|
const fs = await preparer();
|
||||||
await fs.creerDossier(DOCUMENTS, 'corbeille');
|
await fs.creerDossier(DOCUMENTS, 'corbeille');
|
||||||
|
|
@ -225,6 +246,92 @@ describe("fichiers web : ce que l'OPFS refuse", () => {
|
||||||
expect(await fs.lireTexte(DOCUMENTS, 'note.txt')).toBe('note');
|
expect(await fs.lireTexte(DOCUMENTS, 'note.txt')).toBe('note');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sous des paramètres régionaux qui ne sont pas UTF-8, Chromium résout un
|
||||||
|
// nom de dossier hors ASCII sur le dossier qui le porte, sans lever. La
|
||||||
|
// doublure rend ce dossier pour le nom « é » : aucun geste ne lit ni n'écrit
|
||||||
|
// alors dans le dossier qui le porte, où un fichier de même nom attend.
|
||||||
|
test("un dossier dont la poignée est celle du dossier qui le porte lève NOM_NON_CONVERTI, ECRITURE à l'écriture et LECTURE à la lecture, et le dossier qui le porte reste intact", async () => {
|
||||||
|
const fs = await preparer();
|
||||||
|
await fs.ecrireAtomique(DOCUMENTS, 'etat.json', 'parent');
|
||||||
|
const poignee = FileSystemDirectoryHandle.prototype.getDirectoryHandle;
|
||||||
|
vi.spyOn(FileSystemDirectoryHandle.prototype, 'getDirectoryHandle').mockImplementation(
|
||||||
|
async function getDirectoryHandle(nom, options) {
|
||||||
|
return nom === '\u{E9}' ? this : poignee.call(this, nom, options);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const sous = `${CHEMIN_DOCUMENTS}/\u{E9}`;
|
||||||
|
const refus = (chemin, dossier = sous) => ({ chemin, dossier, cause: 'NOM_NON_CONVERTI' });
|
||||||
|
const enfant = '\u{E9}/etat.json';
|
||||||
|
|
||||||
|
await echoue(fs.creerDossier(DOCUMENTS, '\u{E9}'), 'ECRITURE', refus('\u{E9}', CHEMIN_DOCUMENTS), 'creerDossier');
|
||||||
|
await echoue(fs.creerDossier(DOCUMENTS, '\u{E9}/sous'), 'ECRITURE', refus('\u{E9}/sous'), 'creerDossier dessous');
|
||||||
|
await echoue(fs.ecrireAtomique(DOCUMENTS, enfant, 'enfant'), 'ECRITURE', refus(enfant), 'ecrireAtomique');
|
||||||
|
await echoue(fs.ajouterLigne(DOCUMENTS, enfant, 'enfant'), 'ECRITURE', refus(enfant), 'ajouterLigne');
|
||||||
|
await echoue(fs.supprimer(DOCUMENTS, enfant), 'ECRITURE', refus(enfant), 'supprimer');
|
||||||
|
await echoue(fs.deplacer(DOCUMENTS, enfant, 'copie.json'), 'ECRITURE', refus(enfant), 'deplacer depuis');
|
||||||
|
await echoue(fs.deplacer(DOCUMENTS, 'etat.json', '\u{E9}/neuf.json'), 'ECRITURE', refus('\u{E9}/neuf.json'), 'deplacer vers');
|
||||||
|
await echoue(fs.lireTexte(DOCUMENTS, enfant), 'LECTURE', refus(enfant), 'lireTexte');
|
||||||
|
await echoue(fs.lister(DOCUMENTS, '\u{E9}'), 'LECTURE', refus('\u{E9}', CHEMIN_DOCUMENTS), 'lister');
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
expect(await fs.lister(DOCUMENTS, '')).toMatchObject([{ nom: 'etat.json', type: 'fichier' }]);
|
||||||
|
expect(await fs.lireTexte(DOCUMENTS, 'etat.json')).toBe('parent');
|
||||||
|
|
||||||
|
// Le dossier du produit se contrôle comme tout autre : une poignée qui
|
||||||
|
// serait la racine de l'OPFS fait échouer la sonde, qui n'y écrit rien.
|
||||||
|
vi.spyOn(FileSystemDirectoryHandle.prototype, 'getDirectoryHandle').mockImplementation(
|
||||||
|
async function getDirectoryHandle(nom, options) {
|
||||||
|
return nom === DOSSIER ? this : poignee.call(this, nom, options);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(await fs.sonder(DOCUMENTS)).toStrictEqual({ inscriptible: false, cause: 'NOM_NON_CONVERTI' });
|
||||||
|
await echoue(fs.lireTexte(DOCUMENTS, 'etat.json'), 'LECTURE', refus('etat.json', CHEMIN_DOCUMENTS), 'produit');
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
expect(await nomsOpfs()).toStrictEqual([DOSSIER]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sous les mêmes paramètres régionaux, un nom de fichier hors ASCII se
|
||||||
|
// résout lui aussi sur le dossier qui le porte : getFileHandle lève
|
||||||
|
// TypeMismatchError, et getDirectoryHandle rend ce dossier. La doublure
|
||||||
|
// reproduit les deux pour le nom « ô.txt ». Le refus n'est alors ni une
|
||||||
|
// absence ni une entrée d'un autre genre : un état présent ne se lit pas
|
||||||
|
// ABSENT, une cible libre ne se dit pas EXISTE.
|
||||||
|
test("un fichier que l'OPFS résout sur le dossier qui le porte lève NOM_NON_CONVERTI, LECTURE à la lecture et ECRITURE à l'écriture, jamais ABSENT ni EXISTE", async () => {
|
||||||
|
const fs = await preparer();
|
||||||
|
await fs.ecrireAtomique(DOCUMENTS, 'source.txt', 'source');
|
||||||
|
const nom = '\u{F4}.txt';
|
||||||
|
const fichier = FileSystemDirectoryHandle.prototype.getFileHandle;
|
||||||
|
const dossier = FileSystemDirectoryHandle.prototype.getDirectoryHandle;
|
||||||
|
vi.spyOn(FileSystemDirectoryHandle.prototype, 'getFileHandle').mockImplementation(
|
||||||
|
async function getFileHandle(entree, options) {
|
||||||
|
if (entree === nom) throw new DOMException(entree, 'TypeMismatchError');
|
||||||
|
return fichier.call(this, entree, options);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
vi.spyOn(FileSystemDirectoryHandle.prototype, 'getDirectoryHandle').mockImplementation(
|
||||||
|
async function getDirectoryHandle(entree, options) {
|
||||||
|
return entree === nom ? this : dossier.call(this, entree, options);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const refus = { chemin: nom, dossier: CHEMIN_DOCUMENTS, cause: 'NOM_NON_CONVERTI' };
|
||||||
|
|
||||||
|
await echoue(fs.lireTexte(DOCUMENTS, nom), 'LECTURE', refus, 'lireTexte');
|
||||||
|
await echoue(fs.ecrireAtomique(DOCUMENTS, nom, 'x'), 'ECRITURE', refus, 'ecrireAtomique');
|
||||||
|
await echoue(fs.ajouterLigne(DOCUMENTS, nom, 'x'), 'ECRITURE', refus, 'ajouterLigne');
|
||||||
|
await echoue(fs.supprimer(DOCUMENTS, nom), 'ECRITURE', refus, 'supprimer');
|
||||||
|
await echoue(fs.deplacer(DOCUMENTS, nom, 'copie.txt'), 'ECRITURE', refus, 'deplacer depuis');
|
||||||
|
await echoue(fs.deplacer(DOCUMENTS, 'source.txt', nom), 'ECRITURE', refus, 'deplacer vers');
|
||||||
|
|
||||||
|
// Un vrai dossier reste une entrée d'un autre genre : ABSENT à la lecture.
|
||||||
|
await fs.creerDossier(DOCUMENTS, 'corbeille');
|
||||||
|
await echoue(fs.lireTexte(DOCUMENTS, 'corbeille'), 'ABSENT', { chemin: 'corbeille' }, 'vrai dossier');
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
expect(await fs.lister(DOCUMENTS, '')).toMatchObject([
|
||||||
|
{ nom: 'corbeille', type: 'dossier' },
|
||||||
|
{ nom: 'source.txt', type: 'fichier' },
|
||||||
|
]);
|
||||||
|
expect(await fs.lireTexte(DOCUMENTS, 'source.txt')).toBe('source');
|
||||||
|
});
|
||||||
|
|
||||||
// Chaque panne frappe un temps de l'écriture par flux : son ouverture,
|
// Chaque panne frappe un temps de l'écriture par flux : son ouverture,
|
||||||
// l'écriture, la fermeture qui porte le contenu sur la cible.
|
// l'écriture, la fermeture qui porte le contenu sur la cible.
|
||||||
const PANNES = [
|
const PANNES = [
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,18 @@
|
||||||
// schéma du fichier d'état, dont l'analyse n'applique que celle du
|
// schéma du fichier d'état, dont l'analyse n'applique que celle du
|
||||||
// conteneur : fautes applique les autres, avant toute cohérence, par le
|
// conteneur : fautes applique les autres, avant toute cohérence, par le
|
||||||
// parcours même de l'analyse, premiereFaute, qui nomme les chemins à sa
|
// parcours même de l'analyse, premiereFaute, qui nomme les chemins à sa
|
||||||
// manière. Aucune fonction ne modifie ce qu'elle reçoit ; aucune ne lit
|
// manière. Une liste de table porte null à la place d'une chaise vide
|
||||||
// l'horloge ni un aléa.
|
// (§ 8.9) : la règle l'admet à toute place, et fautes ne le garde que sous
|
||||||
|
// des sièges attribués, avant le dernier occupant, siège 1 compris. Aucune
|
||||||
|
// fonction ne modifie ce qu'elle reçoit ; aucune ne lit l'horloge ni un
|
||||||
|
// aléa.
|
||||||
import { SCHEMA, capacite, premiereFaute } from './document.js';
|
import { SCHEMA, capacite, premiereFaute } from './document.js';
|
||||||
import { ErreurStockage } from './erreurs.js';
|
import { ErreurStockage } from './erreurs.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Une faute d'un placement ; types.js en donne la table des codes. FORME :
|
* Une faute d'un placement ; types.js en donne la table des codes. FORME :
|
||||||
* une valeur sort de sa règle, ou la déclaration se contredit ; chemin
|
* une valeur sort de sa règle, la déclaration se contredit, ou une chaise
|
||||||
|
* vide est mal placée — sans attribution des sièges, ou en fin de liste ; chemin
|
||||||
* désigne l'élément fautif dans le placement, à la manière de l'analyse du
|
* désigne l'élément fautif dans le placement, à la manière de l'analyse du
|
||||||
* fichier, '' pour le placement lui-même. LONGUEUR : une longueur écrite
|
* fichier, '' pour le placement lui-même. LONGUEUR : une longueur écrite
|
||||||
* contredit celle que le placement déclare ; tour et table null désignent le
|
* contredit celle que le placement déclare ; tour et table null désignent le
|
||||||
|
|
@ -66,8 +70,9 @@ import { ErreurStockage } from './erreurs.js';
|
||||||
* @property {number|null} numero numéro affiché de la table dans la
|
* @property {number|null} numero numéro affiché de la table dans la
|
||||||
* charge ; null pour la réserve et pour
|
* charge ; null pour la réserve et pour
|
||||||
* une table que la charge n'a plus
|
* une table que la charge n'a plus
|
||||||
* @property {number|null} siege rang dans la liste, à partir de 1, quand
|
* @property {number|null} siege rang dans la liste, à partir de 1,
|
||||||
* les sièges sont attribués ; null sinon
|
* chaises vides comprises, quand les
|
||||||
|
* sièges sont attribués ; null sinon
|
||||||
* @property {number} participant
|
* @property {number} participant
|
||||||
* @property {string|null} nom null pour une personne que la charge
|
* @property {string|null} nom null pour une personne que la charge
|
||||||
* n'a plus
|
* n'a plus
|
||||||
|
|
@ -93,8 +98,9 @@ import { ErreurStockage } from './erreurs.js';
|
||||||
const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
|
const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
|
||||||
const CHARGE = regleDuChamp(SCHEMA, 'charge');
|
const CHARGE = regleDuChamp(SCHEMA, 'charge');
|
||||||
// Règles d'une proposition et du retenu. Aucune des valeurs qu'elles
|
// Règles d'une proposition et du retenu. Aucune des valeurs qu'elles
|
||||||
// contiennent n'admet null ; examiner traite le retenu nul, son absence,
|
// contiennent n'admet null, hors de l'occupant d'une liste de table, la
|
||||||
// avant de lire le retenu.
|
// chaise vide ; examiner traite le retenu nul, son absence, avant de lire
|
||||||
|
// le retenu.
|
||||||
const PROPOSITION = regleDuChamp(CHARGE, 'propositions').element;
|
const PROPOSITION = regleDuChamp(CHARGE, 'propositions').element;
|
||||||
const RETENU = regleDuChamp(CHARGE, 'retenu');
|
const RETENU = regleDuChamp(CHARGE, 'retenu');
|
||||||
const IDENTIFIANT = regleDuChamp(PROPOSITION, 'id');
|
const IDENTIFIANT = regleDuChamp(PROPOSITION, 'id');
|
||||||
|
|
@ -127,14 +133,32 @@ function fauteDeDeclaration({ tables, capacites, participants }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chemin de la première chaise vide mal placée d'un placement de forme et
|
||||||
|
// de déclaration conformes, ou null, dans l'ordre des tours, des listes et
|
||||||
|
// des rangs : un null sous siegesAttribues faux, où l'ordre d'une liste ne
|
||||||
|
// dit aucun siège, ou au dernier rang d'une liste, qu'une écriture sans
|
||||||
|
// lui dit pareil. Le chemin suit la convention de premiereFaute.
|
||||||
|
function fauteDeChaiseVide({ siegesAttribues, placement }) {
|
||||||
|
for (let tour = 0; tour < placement.length; tour += 1) {
|
||||||
|
const { sieges } = placement[tour];
|
||||||
|
for (let table = 0; table < sieges.length; table += 1) {
|
||||||
|
const liste = sieges[table];
|
||||||
|
const rang = siegesAttribues ? liste.length - 1 : liste.indexOf(null);
|
||||||
|
if (rang >= 0 && liste[rang] === null) return `placement[${tour}].sieges[${table}][${rang}]`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Fautes de cohérence d'un placement de forme et de déclaration conformes
|
// Fautes de cohérence d'un placement de forme et de déclaration conformes
|
||||||
// (§ 8.9, points 1 et 2). D'abord le nombre de tours écrits contre celui
|
// (§ 8.9, points 1 et 2). D'abord le nombre de tours écrits contre celui
|
||||||
// déclaré. Puis, tour après tour : le nombre de listes de table contre celui
|
// déclaré. Puis, tour après tour : le nombre de listes de table contre celui
|
||||||
// des tables déclarées ; la longueur de chaque liste appariée à une table
|
// des tables déclarées ; la longueur de chaque liste appariée à une table
|
||||||
// déclarée, dans l'ordre déclaré, contre sa capacité ; le multiensemble du
|
// déclarée, dans l'ordre déclaré, contre sa capacité, chaises vides
|
||||||
// tour, listes de table et réserve comprises, où chaque identifiant déclaré
|
// comprises ; le multiensemble du tour, listes de table et réserve
|
||||||
// figure exactement une fois — doublons, inconnus, puis manquants, chacun
|
// comprises, où chaque identifiant déclaré figure exactement une fois —
|
||||||
// nommé une fois par tour, par identifiant croissant.
|
// doublons, inconnus, puis manquants, chacun nommé une fois par tour, par
|
||||||
|
// identifiant croissant. Une chaise vide n'y entre pas.
|
||||||
//
|
//
|
||||||
// La capacité borne une liste sans la fixer : quand la salle offre plus de
|
// La capacité borne une liste sans la fixer : quand la salle offre plus de
|
||||||
// sièges que de personnes, des tables gardent des places vides, et une liste
|
// sièges que de personnes, des tables gardent des places vides, et une liste
|
||||||
|
|
@ -167,6 +191,7 @@ function fautesDeCoherence({ tables, capacites, tours, participants, placement }
|
||||||
const inconnus = [];
|
const inconnus = [];
|
||||||
for (const ids of [...sieges, reserve]) {
|
for (const ids of [...sieges, reserve]) {
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
|
if (id === null) continue;
|
||||||
const deja = occurrences.get(id) ?? 0;
|
const deja = occurrences.get(id) ?? 0;
|
||||||
occurrences.set(id, deja + 1);
|
occurrences.set(id, deja + 1);
|
||||||
if (deja === 0 && !estDeclare.has(id)) inconnus.push(id);
|
if (deja === 0 && !estDeclare.has(id)) inconnus.push(id);
|
||||||
|
|
@ -183,11 +208,11 @@ function fautesDeCoherence({ tables, capacites, tours, participants, placement }
|
||||||
return liste;
|
return liste;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fautes de valeur sous regle : la première faute de forme ou de
|
// Fautes de valeur sous regle : la première faute de forme, de
|
||||||
// déclaration seule, qui rend la cohérence illisible ; sinon les fautes de
|
// déclaration ou de chaise vide seule, qui rend la cohérence illisible ;
|
||||||
// cohérence.
|
// sinon les fautes de cohérence.
|
||||||
function fautesSelon(valeur, regle) {
|
function fautesSelon(valeur, regle) {
|
||||||
const chemin = premiereFaute(valeur, regle) ?? fauteDeDeclaration(valeur);
|
const chemin = premiereFaute(valeur, regle) ?? fauteDeDeclaration(valeur) ?? fauteDeChaiseVide(valeur);
|
||||||
return chemin === null ? fautesDeCoherence(valeur) : [{ code: 'FORME', chemin }];
|
return chemin === null ? fautesDeCoherence(valeur) : [{ code: 'FORME', chemin }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,7 +222,9 @@ function fautesSelon(valeur, regle) {
|
||||||
* retenu quand p porte la clé proposition, celle d'une proposition sinon.
|
* retenu quand p porte la clé proposition, celle d'une proposition sinon.
|
||||||
*
|
*
|
||||||
* La forme se contrôle d'abord, dans l'ordre du schéma, puis la
|
* La forme se contrôle d'abord, dans l'ordre du schéma, puis la
|
||||||
* déclaration : la première faute de l'une ou de l'autre est seule rendue,
|
* déclaration, puis les chaises vides : null, admis par la règle d'une
|
||||||
|
* liste de table, n'y est cohérent que sous des sièges attribués et avant
|
||||||
|
* le dernier rang. La première de ces fautes est seule rendue,
|
||||||
* { code: 'FORME', chemin }. Sinon, dans l'ordre des tours puis des tables :
|
* { code: 'FORME', chemin }. Sinon, dans l'ordre des tours puis des tables :
|
||||||
* { code: 'LONGUEUR', tour, table, declare, ecrit } — tour null pour le
|
* { code: 'LONGUEUR', tour, table, declare, ecrit } — tour null pour le
|
||||||
* nombre de tours, rendu en premier ; table null pour le nombre de listes
|
* nombre de tours, rendu en premier ; table null pour le nombre de listes
|
||||||
|
|
@ -248,8 +275,9 @@ function exigerCoherent(p, fonction) {
|
||||||
* configuration, lue par identifiant ; le nombre de tours ; les participants
|
* configuration, lue par identifiant ; le nombre de tours ; les participants
|
||||||
* non exclus, par identifiant croissant. Le placement reprend le plan tour
|
* non exclus, par identifiant croissant. Le placement reprend le plan tour
|
||||||
* par tour : sans attribution de sièges, chaque liste de table se trie par
|
* par tour : sans attribution de sièges, chaque liste de table se trie par
|
||||||
* identifiant croissant ; avec, elle garde son ordre, celui des sièges. La
|
* identifiant croissant ; avec, elle garde son ordre, celui des sièges, et
|
||||||
* réserve, un ensemble, se trie dans les deux cas. Les clés suivent l'ordre
|
* ses chaises vides, null. La réserve, un ensemble, se trie dans les deux
|
||||||
|
* cas. Les clés suivent l'ordre
|
||||||
* du schéma ; rien n'est partagé avec les arguments, qui ne sont pas
|
* du schéma ; rien n'est partagé avec les arguments, qui ne sont pas
|
||||||
* modifiés.
|
* modifiés.
|
||||||
*
|
*
|
||||||
|
|
@ -263,7 +291,8 @@ function exigerCoherent(p, fonction) {
|
||||||
* entier ≥ 0, ou quand la forme produite serait fautive : attribuerSieges
|
* entier ≥ 0, ou quand la forme produite serait fautive : attribuerSieges
|
||||||
* qui n'est pas un booléen, version vide ou absente, réglage hors de son
|
* qui n'est pas un booléen, version vide ou absente, réglage hors de son
|
||||||
* domaine, plan que la configuration contredit — table inconnue d'elle,
|
* domaine, plan que la configuration contredit — table inconnue d'elle,
|
||||||
* nombre de tours, personne non placée, liste au-delà d'une capacité.
|
* nombre de tours, personne non placée, liste au-delà d'une capacité,
|
||||||
|
* chaise vide sans attribution ou en fin de liste.
|
||||||
*
|
*
|
||||||
* @param {import('../moteur/recherche.js').Proposition} proposition
|
* @param {import('../moteur/recherche.js').Proposition} proposition
|
||||||
* @param {import('../moteur/types.js').Configuration} configuration
|
* @param {import('../moteur/types.js').Configuration} configuration
|
||||||
|
|
@ -309,9 +338,9 @@ export function versFichier(proposition, configuration, { produitVersion, attrib
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plan du moteur d'un placement de fichier, proposition ou retenu : les
|
* Plan du moteur d'un placement de fichier, proposition ou retenu : les
|
||||||
* tables dans l'ordre déclaré, chaque liste de table et chaque réserve par
|
* tables dans l'ordre déclaré, chaque liste de table sans ses chaises
|
||||||
* identifiant croissant, comme le moteur les rend ; l'ordre des sièges n'y
|
* vides, et chaque réserve, par identifiant croissant, comme le moteur les
|
||||||
* entre pas. Aucune liste n'est partagée avec p. Lève TypeError quand p
|
* rend ; l'ordre des sièges n'y entre pas. Aucune liste n'est partagée avec p. Lève TypeError quand p
|
||||||
* n'est pas cohérent (fautes).
|
* n'est pas cohérent (fautes).
|
||||||
*
|
*
|
||||||
* @param {import('./types.js').PropositionFichier|import('./types.js').Retenu} p
|
* @param {import('./types.js').PropositionFichier|import('./types.js').Retenu} p
|
||||||
|
|
@ -321,7 +350,7 @@ export function planDepuisFichier(p) {
|
||||||
exigerCoherent(p, 'planDepuisFichier');
|
exigerCoherent(p, 'planDepuisFichier');
|
||||||
return {
|
return {
|
||||||
tables: [...p.tables],
|
tables: [...p.tables],
|
||||||
tours: p.placement.map(({ sieges }) => sieges.map((liste) => [...liste].sort(croissant))),
|
tours: p.placement.map(({ sieges }) => sieges.map((liste) => liste.filter((id) => id !== null).sort(croissant))),
|
||||||
reserves: p.placement.map(({ reserve }) => [...reserve].sort(croissant)),
|
reserves: p.placement.map(({ reserve }) => [...reserve].sort(croissant)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -411,7 +440,8 @@ const ordreDesTables = (a, b) =>
|
||||||
* l'identifiant départage deux numéros égaux —, puis par rang dans la
|
* l'identifiant départage deux numéros égaux —, puis par rang dans la
|
||||||
* liste ; la réserve suit les tables de son tour, table et numéro null.
|
* liste ; la réserve suit les tables de son tour, table et numéro null.
|
||||||
* siege est le rang à partir de 1 quand le placement attribue les sièges,
|
* siege est le rang à partir de 1 quand le placement attribue les sièges,
|
||||||
* null sinon et dans la réserve. Nom et prénom viennent de la charge ; une
|
* null sinon et dans la réserve ; une chaise vide, null dans la liste,
|
||||||
|
* garde son rang et n'a pas de ligne. Nom et prénom viennent de la charge ; une
|
||||||
* personne qu'elle n'a plus, ou qu'elle n'a jamais eue, les a null.
|
* personne qu'elle n'a plus, ou qu'elle n'a jamais eue, les a null.
|
||||||
*
|
*
|
||||||
* Un placement incohérent (fautes : LONGUEUR, DOUBLON, INCONNU, MANQUANT)
|
* Un placement incohérent (fautes : LONGUEUR, DOUBLON, INCONNU, MANQUANT)
|
||||||
|
|
@ -454,6 +484,7 @@ export function formeNommee(p, charge) {
|
||||||
// au-delà des tables déclarées.
|
// au-delà des tables déclarées.
|
||||||
const ajouterListe = (tour, ids, table, numero) => {
|
const ajouterListe = (tour, ids, table, numero) => {
|
||||||
ids.forEach((participant, position) => {
|
ids.forEach((participant, position) => {
|
||||||
|
if (participant === null) return;
|
||||||
ajouter(tour, table, numero, p.siegesAttribues ? position + 1 : null, participant, false);
|
ajouter(tour, table, numero, p.siegesAttribues ? position + 1 : null, participant, false);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1495,3 +1495,140 @@ describe('déterminisme (§ 8.8, § 14.7)', () => {
|
||||||
assert.deepStrictEqual(fautes(alphaPuisZeta), [{ code: 'FORME', chemin: 'alpha' }]);
|
assert.deepStrictEqual(fautes(alphaPuisZeta), [{ code: 'FORME', chemin: 'alpha' }]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// fichierParfait, sièges attribués, dont le tour 1 laisse vide le siège 2
|
||||||
|
// de la table 1 : sa liste vaut [12, null, 7], et la personne 2 attend en
|
||||||
|
// réserve. Une copie neuve à chaque appel.
|
||||||
|
function fichierAChaiseVide() {
|
||||||
|
const p = fichierParfait();
|
||||||
|
p.siegesAttribues = true;
|
||||||
|
p.placement[0] = { sieges: [[12, null, 7], [4, 5, 6], [1, 8, 9], [10, 11, 3]], reserve: [2] };
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('le marqueur de chaise vide : null avant le dernier occupant, sièges attribués (§ 8.9)', () => {
|
||||||
|
test("une liste [12, null, 7] est cohérente, proposition comme retenu : le null compte dans la longueur, n'est ni DOUBLON ni INCONNU, et planDepuisFichier le retire", () => {
|
||||||
|
const p = fichierAChaiseVide();
|
||||||
|
assert.deepStrictEqual(fautes(p), []);
|
||||||
|
assert.deepStrictEqual(fautes(retenuDe(p)), []);
|
||||||
|
assert.deepStrictEqual(planDepuisFichier(p).tours[0], [[7, 12], [4, 5, 6], [1, 8, 9], [3, 10, 11]]);
|
||||||
|
assert.deepStrictEqual(planDepuisFichier(retenuDe(p)).tours[0][0], [7, 12]);
|
||||||
|
// Deux chaises vides au même tour, à deux tables : aucune n'est un
|
||||||
|
// doublon de l'autre.
|
||||||
|
const deux = fichierAChaiseVide();
|
||||||
|
deux.placement[0] = { sieges: [[12, null, 7], [4, null, 6], [1, 8, 9], [10, 11, 3]], reserve: [2, 5] };
|
||||||
|
assert.deepStrictEqual(fautes(deux), []);
|
||||||
|
assert.deepStrictEqual(planDepuisFichier(deux).tours[0].slice(0, 2), [[7, 12], [4, 6]]);
|
||||||
|
// Le null occupe un siège : quatre éléments sur une capacité de 3.
|
||||||
|
const longue = fichierAChaiseVide();
|
||||||
|
longue.placement[0] = { sieges: [[12, null, 7, 9], [4, 5, 6], [1, 8], [10, 11, 3]], reserve: [2] };
|
||||||
|
assert.deepStrictEqual(fautes(longue), [{ code: 'LONGUEUR', tour: 1, table: 1, declare: 3, ecrit: 4 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une chaise vide au siège 1, [null, 12, 7] ou [null, null, 7], est cohérente, proposition comme retenu : planDepuisFichier la retire, et formeNommee garde le siège de chaque occupant", () => {
|
||||||
|
const cas = [
|
||||||
|
[[null, 12, 7], [2], [7, 12], [ligne(1, 1, 1, 2, 12, 'Mélisse', 'Mathurin'), ligne(1, 1, 1, 3, 7, 'Giroflée', 'Gisèle')]],
|
||||||
|
[[null, null, 7], [2, 12], [7], [ligne(1, 1, 1, 3, 7, 'Giroflée', 'Gisèle')]],
|
||||||
|
];
|
||||||
|
assert.ok(cas.length > 0);
|
||||||
|
for (const [liste, reserve, occupants, assis] of cas) {
|
||||||
|
const p = fichierAChaiseVide();
|
||||||
|
p.placement[0] = { sieges: [liste, [4, 5, 6], [1, 8, 9], [10, 11, 3]], reserve };
|
||||||
|
assert.deepStrictEqual(fautes(p), [], JSON.stringify(liste));
|
||||||
|
assert.deepStrictEqual(fautes(retenuDe(p)), [], JSON.stringify(liste));
|
||||||
|
assert.deepStrictEqual(planDepuisFichier(p).tours[0][0], occupants);
|
||||||
|
assert.deepStrictEqual(planDepuisFichier(retenuDe(p)).tours[0][0], occupants);
|
||||||
|
const lignes = formeNommee(p, chargePetite());
|
||||||
|
assert.deepStrictEqual(lignes.filter(({ tour, table: id }) => tour === 1 && id === 1), assis);
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
lignes.filter(({ tour, reserve: enReserve }) => tour === 1 && enReserve).map(({ participant }) => participant),
|
||||||
|
reserve,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sans attribution, ou en fin de liste, null est une faute FORME qui nomme la chaise ; seul sous sa règle, il ne s'admet nulle part ailleurs dans un placement", () => {
|
||||||
|
const cas = [
|
||||||
|
['sièges non attribués', (p) => { p.siegesAttribues = false; }, 'placement[0].sieges[0][1]'],
|
||||||
|
['une liste qui finit par null', (p) => {
|
||||||
|
p.placement[0] = { sieges: [[12, null], [4, 5, 6], [1, 8, 9], [10, 11, 3]], reserve: [2, 7] };
|
||||||
|
}, 'placement[0].sieges[0][1]'],
|
||||||
|
['une liste réduite à null', (p) => {
|
||||||
|
p.placement[1].sieges[3] = [null];
|
||||||
|
p.placement[1].reserve = [1, 4, 7];
|
||||||
|
}, 'placement[1].sieges[3][0]'],
|
||||||
|
['une chaise vide admise au tour 1, puis une fin nulle au tour 3', (p) => {
|
||||||
|
p.placement[2].sieges[0] = [4, 8, null];
|
||||||
|
p.placement[2].reserve = [10];
|
||||||
|
}, 'placement[2].sieges[0][2]'],
|
||||||
|
['une fin nulle au dernier tour, à la dernière table', (p) => {
|
||||||
|
p.placement[3].sieges[3] = [2, 6, null];
|
||||||
|
p.placement[3].reserve = [8];
|
||||||
|
}, 'placement[3].sieges[3][2]'],
|
||||||
|
['sièges non attribués, une chaise vide au siège 1', (p) => {
|
||||||
|
p.siegesAttribues = false;
|
||||||
|
p.placement[0] = { sieges: [[null, 7, 12], [4, 5, 6], [1, 8, 9], [3, 10, 11]], reserve: [2] };
|
||||||
|
}, 'placement[0].sieges[0][0]'],
|
||||||
|
['null dans la réserve', (p) => { p.placement[0].reserve = [2, null]; }, 'placement[0].reserve[1]'],
|
||||||
|
['null parmi les participants', (p) => { p.participants[11] = null; }, 'participants[11]'],
|
||||||
|
['null parmi les tables', (p) => { p.tables[3] = null; }, 'tables[3]'],
|
||||||
|
];
|
||||||
|
assert.ok(cas.length > 0);
|
||||||
|
const ecarts = cas.flatMap(([libelle, abimer, chemin]) =>
|
||||||
|
[fichierAChaiseVide(), retenuDe(fichierAChaiseVide())].flatMap((p) => {
|
||||||
|
abimer(p);
|
||||||
|
const rendu = fautes(p);
|
||||||
|
const attendu = [{ code: 'FORME', chemin }];
|
||||||
|
return JSON.stringify(rendu) === JSON.stringify(attendu) ? [] : [`${libelle} : ${JSON.stringify(rendu)}`];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.deepEqual(ecarts, []);
|
||||||
|
leveTypeError(() => planDepuisFichier({ ...fichierAChaiseVide(), siegesAttribues: false }), /FORME/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formeNommee assied 12 au siège 1 et 7 au siège 3, sans ligne pour la chaise vide', () => {
|
||||||
|
const lignes = formeNommee(fichierAChaiseVide(), chargePetite());
|
||||||
|
assert.equal(lignes.length, 48);
|
||||||
|
assert.ok(lignes.every(({ participant }) => participant !== null));
|
||||||
|
assert.deepStrictEqual(lignes.filter(({ tour, table: id }) => tour === 1 && id === 1), [
|
||||||
|
ligne(1, 1, 1, 1, 12, 'Mélisse', 'Mathurin'),
|
||||||
|
ligne(1, 1, 1, 3, 7, 'Giroflée', 'Gisèle'),
|
||||||
|
]);
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
lignes.filter(({ tour, reserve }) => tour === 1 && reserve),
|
||||||
|
[ligne(1, null, null, null, 2, 'Bruyère', 'Basile')],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('versFichier garde les chaises vides du plan reçu quand les sièges sont attribués, et lève sans attribution', () => {
|
||||||
|
const plan = {
|
||||||
|
tables: [1, 2, 3, 4],
|
||||||
|
tours: [[[12, null, 7], [4, 5, 6], [1, 8, 9], [10, 11, 3]], ...PLAN_PARFAIT_PETITE.tours.slice(1)],
|
||||||
|
reserves: [[2], [], [], []],
|
||||||
|
};
|
||||||
|
const proposition = { ...PARFAITE, plan };
|
||||||
|
const options = { produitVersion: VERSION.affichee, decalage: 0 };
|
||||||
|
const fichier = versFichier(proposition, petite(), { ...options, attribuerSieges: true });
|
||||||
|
assert.deepStrictEqual(fichier.placement[0].sieges, [[12, null, 7], [4, 5, 6], [1, 8, 9], [10, 11, 3]]);
|
||||||
|
assert.deepStrictEqual(fichier, fichierAChaiseVide());
|
||||||
|
leveTypeError(() => versFichier(proposition, petite(), { ...options, attribuerSieges: false }), /FORME/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le fichier relu porte la liste telle quelle : examiner garde la proposition et le retenu sans faute, et écarte la proposition sans attribution qui porte null", () => {
|
||||||
|
const charge = chargePetite();
|
||||||
|
const proposition = fichierAChaiseVide();
|
||||||
|
charge.propositions = [proposition, { ...fichierAChaiseVide(), id: 2, siegesAttribues: false }];
|
||||||
|
charge.retenu = retenuDe(proposition);
|
||||||
|
charge.prochainsIds.proposition = 3;
|
||||||
|
charge.evenement.etat = 'retenu';
|
||||||
|
const relue = relire(charge);
|
||||||
|
assert.deepStrictEqual(relue.retenu.placement[0].sieges[0], [12, null, 7]);
|
||||||
|
assert.deepStrictEqual(relue.propositions[0].placement[0].sieges[0], [12, null, 7]);
|
||||||
|
const examen = examiner(relue);
|
||||||
|
assert.deepStrictEqual(examen.retenu, { fautes: [], raisons: [] });
|
||||||
|
assert.deepStrictEqual(examen.charge.propositions, [fichierAChaiseVide()]);
|
||||||
|
assert.deepStrictEqual(examen.fautives, [
|
||||||
|
{ rang: 1, id: 2, fautes: [{ code: 'FORME', chemin: 'placement[0].sieges[0][0]' }] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
// Gardes de l'arborescence, lues dans le texte des sources : le moteur, le
|
// Gardes de l'arborescence, lues dans le texte des sources : le moteur, la
|
||||||
// générateur de démonstrations, le stockage et l'analyseur CSV n'appellent
|
// géométrie du plan, le générateur de démonstrations, le stockage et
|
||||||
// aucune source non reproductible (§ 14.7), et ne parcourent les clés d'un
|
// l'analyseur CSV n'appellent aucune source non reproductible (§ 14.7), et ne parcourent les clés d'un
|
||||||
// objet que par exception nommée (§ 15.5, point 4) ; le graphe d'imports de
|
// objet que par exception nommée (§ 15.5, point 4) ; le graphe d'imports de
|
||||||
// chaque couche ne rejoint que ce que le § 13.4 lui permet ; chaque paquet
|
// chaque couche ne rejoint que ce que le § 13.4 lui permet ; chaque paquet
|
||||||
// importé est déclaré dans devDependencies ; une valeur du contrat de données
|
// importé est déclaré dans devDependencies ; une valeur du contrat de données
|
||||||
|
|
@ -138,10 +138,10 @@ const PARCOURS_DE_CLES_ADMIS = [
|
||||||
];
|
];
|
||||||
|
|
||||||
// Arbres dont les modules ne lisent aucune source non reproductible (§ 14.7) :
|
// Arbres dont les modules ne lisent aucune source non reproductible (§ 14.7) :
|
||||||
// le moteur et le générateur de démonstrations, puis le stockage et
|
// le moteur, la géométrie du plan et le générateur de démonstrations, puis le
|
||||||
// l'analyseur CSV, qui reçoivent l'horloge et l'aléa de l'application, en
|
// stockage et l'analyseur CSV, qui reçoivent l'horloge et l'aléa de
|
||||||
// paramètre.
|
// l'application, en paramètre.
|
||||||
const ARBRES_DETERMINISTES = ['src/moteur', 'src/demo', 'src/stockage', 'src/csv'];
|
const ARBRES_DETERMINISTES = ['src/moteur', 'src/geometrie', 'src/demo', 'src/stockage', 'src/csv'];
|
||||||
|
|
||||||
// Vrai quand le motif se lit dans le texte à partir de l'indice donné, et non
|
// Vrai quand le motif se lit dans le texte à partir de l'indice donné, et non
|
||||||
// plus loin : une copie collante du motif, ses drapeaux g et y retirés.
|
// plus loin : une copie collante du motif, ses drapeaux g et y retirés.
|
||||||
|
|
@ -196,6 +196,7 @@ function releverDeterminisme(racine, admis = PARCOURS_DE_CLES_ADMIS) {
|
||||||
// pour éprouver le refus d'un balayage vide.
|
// pour éprouver le refus d'un balayage vide.
|
||||||
const MODULES_VIDES = Object.freeze({
|
const MODULES_VIDES = Object.freeze({
|
||||||
'src/moteur/a.js': '',
|
'src/moteur/a.js': '',
|
||||||
|
'src/geometrie/a.js': '',
|
||||||
'src/demo/a.js': '',
|
'src/demo/a.js': '',
|
||||||
'src/stockage/a.js': '',
|
'src/stockage/a.js': '',
|
||||||
'src/csv/a.js': '',
|
'src/csv/a.js': '',
|
||||||
|
|
@ -210,7 +211,7 @@ const sansArbre = (fichiers, arbre) =>
|
||||||
const releverSansException = (racine) => releverDeterminisme(racine, []);
|
const releverSansException = (racine) => releverDeterminisme(racine, []);
|
||||||
|
|
||||||
describe('arborescence : déterminisme (§ 14.7)', () => {
|
describe('arborescence : déterminisme (§ 14.7)', () => {
|
||||||
test("ni src/moteur, ni src/demo, ni src/stockage, ni src/csv ne lisent une source non reproductible, ni ne parcourent les clés d'un objet hors des exceptions nommées", () => {
|
test("ni src/moteur, ni src/geometrie, ni src/demo, ni src/stockage, ni src/csv ne lisent une source non reproductible, ni ne parcourent les clés d'un objet hors des exceptions nommées", () => {
|
||||||
assert.deepEqual(releverDeterminisme(RACINE), []);
|
assert.deepEqual(releverDeterminisme(RACINE), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -315,7 +316,7 @@ describe('arborescence : déterminisme (§ 14.7)', () => {
|
||||||
'src/demo/tirage.cjs:1 Date.now',
|
'src/demo/tirage.cjs:1 Date.now',
|
||||||
'src/demo/tirage.mjs:1 Math.random',
|
'src/demo/tirage.mjs:1 Math.random',
|
||||||
]);
|
]);
|
||||||
for (const arbre of ['src/moteur', 'src/demo', 'src/stockage', 'src/csv']) {
|
for (const arbre of ['src/moteur', 'src/geometrie', 'src/demo', 'src/stockage', 'src/csv']) {
|
||||||
const sansModule = { ...sansArbre(MODULES_VIDES, arbre), [`${arbre}/a.test.js`]: '' };
|
const sansModule = { ...sansArbre(MODULES_VIDES, arbre), [`${arbre}/a.test.js`]: '' };
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => avecArbre(sansModule, releverSansException),
|
() => avecArbre(sansModule, releverSansException),
|
||||||
|
|
@ -325,6 +326,24 @@ describe('arborescence : déterminisme (§ 14.7)', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('src/geometrie se relève comme le moteur, à toute profondeur, hors épreuves', () => {
|
||||||
|
const fichiers = {
|
||||||
|
...MODULES_VIDES,
|
||||||
|
'src/geometrie/vue.js': [
|
||||||
|
'export const a = Math.random();',
|
||||||
|
'export const b = (o) => Object.keys(o).map((cle) => o[cle]);',
|
||||||
|
'export const c = Math.min(1, 2) + Math.floor(2.5);',
|
||||||
|
].join('\n'),
|
||||||
|
'src/geometrie/sous/texte.js': 'for (const champ in ligne) t.push(champ);\n',
|
||||||
|
'src/geometrie/vue.test.js': 'export const d = Math.random() + Object.keys(o).length;\n',
|
||||||
|
};
|
||||||
|
assert.deepEqual(avecArbre(fichiers, releverSansException), [
|
||||||
|
'src/geometrie/sous/texte.js:1 for…in',
|
||||||
|
'src/geometrie/vue.js:1 Math.random',
|
||||||
|
'src/geometrie/vue.js:2 Object.keys',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('src/stockage et src/csv se relèvent comme le moteur et le générateur, à toute profondeur, hors épreuves', () => {
|
test('src/stockage et src/csv se relèvent comme le moteur et le générateur, à toute profondeur, hors épreuves', () => {
|
||||||
const fichiers = {
|
const fichiers = {
|
||||||
...MODULES_VIDES,
|
...MODULES_VIDES,
|
||||||
|
|
@ -470,7 +489,9 @@ const PAQUETS_DE_PLATEFORME = /^(?:svelte|electron|@capacitor\/.+)$/;
|
||||||
// l'application.
|
// l'application.
|
||||||
//
|
//
|
||||||
// Le moteur et la géométrie ne connaissent ni l'écran, ni les fichiers, ni ce
|
// Le moteur et la géométrie ne connaissent ni l'écran, ni les fichiers, ni ce
|
||||||
// qui les commande. Le stockage lit et écrit par le système de fichiers qu'il
|
// qui les commande. La géométrie est une couche à part, plus bas que le
|
||||||
|
// moteur : des fonctions pures du plan, qui n'importent ni le moteur, ni
|
||||||
|
// l'analyseur CSV, ni le générateur de démonstrations. Le stockage lit et écrit par le système de fichiers qu'il
|
||||||
// reçoit ; seules ses deux implémentations de plateforme touchent le
|
// reçoit ; seules ses deux implémentations de plateforme touchent le
|
||||||
// navigateur, par l'OPFS sous web et par le pont window.gtt sous Electron.
|
// navigateur, par l'OPFS sous web et par le pont window.gtt sous Electron.
|
||||||
// L'analyseur CSV lit le modèle du stockage. L'application câble le moteur,
|
// L'analyseur CSV lit le modèle du stockage. L'application câble le moteur,
|
||||||
|
|
@ -478,12 +499,19 @@ const PAQUETS_DE_PLATEFORME = /^(?:svelte|electron|@capacitor\/.+)$/;
|
||||||
// l'interface.
|
// l'interface.
|
||||||
const COUCHES = [
|
const COUCHES = [
|
||||||
{
|
{
|
||||||
arbres: ['src/moteur', 'src/geometrie'],
|
arbres: ['src/moteur'],
|
||||||
requis: ['src/moteur'],
|
requis: ['src/moteur'],
|
||||||
exterieures: ['interface', 'application', 'stockage'],
|
exterieures: ['interface', 'application', 'stockage'],
|
||||||
paquets: PAQUETS_DE_PLATEFORME,
|
paquets: PAQUETS_DE_PLATEFORME,
|
||||||
navigateur: [],
|
navigateur: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
arbres: ['src/geometrie'],
|
||||||
|
requis: ['src/geometrie'],
|
||||||
|
exterieures: ['interface', 'application', 'stockage', 'moteur', 'csv', 'demo'],
|
||||||
|
paquets: PAQUETS_DE_PLATEFORME,
|
||||||
|
navigateur: [],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
arbres: ['src/stockage'],
|
arbres: ['src/stockage'],
|
||||||
requis: ['src/stockage'],
|
requis: ['src/stockage'],
|
||||||
|
|
@ -815,10 +843,10 @@ describe('arborescence : frontière des couches (§ 13.4)', () => {
|
||||||
'src/moteur/a.js:26 import ../application/seance.js',
|
'src/moteur/a.js:26 import ../application/seance.js',
|
||||||
'src/moteur/b.js:4 import ../interface/App.svelte',
|
'src/moteur/b.js:4 import ../interface/App.svelte',
|
||||||
'src/moteur/sous/c.js:1 import ../../interface/x.js',
|
'src/moteur/sous/c.js:1 import ../../interface/x.js',
|
||||||
|
'src/moteur/a.js → src/commun/aide.js:2 window.',
|
||||||
'src/geometrie/echelle.js:1 window.',
|
'src/geometrie/echelle.js:1 window.',
|
||||||
'src/geometrie/echelle.js:3 import ../application/commandes.js',
|
'src/geometrie/echelle.js:3 import ../application/commandes.js',
|
||||||
'src/geometrie/echelle.js:4 import ../application/seance.js',
|
'src/geometrie/echelle.js:4 import ../application/seance.js',
|
||||||
'src/moteur/a.js → src/commun/aide.js:2 window.',
|
|
||||||
'src/geometrie/echelle.js → src/commun/racine.js:1 window.',
|
'src/geometrie/echelle.js → src/commun/racine.js:1 window.',
|
||||||
'src/stockage/fichiers.js:1 import @capacitor/core',
|
'src/stockage/fichiers.js:1 import @capacitor/core',
|
||||||
]);
|
]);
|
||||||
|
|
@ -989,10 +1017,57 @@ describe('arborescence : frontière des couches (§ 13.4)', () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('src/geometrie et src/application peuvent manquer ; un src/moteur, un src/stockage ou un src/csv sans module fait échouer la garde', () => {
|
test("src/geometrie n'importe ni le moteur, ni le CSV, ni les démonstrations : une couche à part, plus bas que le moteur", () => {
|
||||||
|
const fichiers = {
|
||||||
|
...MODULES_VIDES,
|
||||||
|
'src/geometrie/vue.js': [
|
||||||
|
"import { mesurer } from '../moteur/indicateurs.js';",
|
||||||
|
"import { decouper } from '../csv/lecture.js';",
|
||||||
|
"import { creerPcg32 } from '../demo/prng.js';",
|
||||||
|
"import { tronquer } from './texte.js';",
|
||||||
|
].join('\n'),
|
||||||
|
'src/geometrie/texte.js': 'export const tronquer = (t) => t;\n',
|
||||||
|
'src/moteur/indicateurs.js': 'export const mesurer = () => 0;\n',
|
||||||
|
'src/csv/lecture.js': 'export const decouper = () => [];\n',
|
||||||
|
'src/demo/prng.js': 'export const creerPcg32 = () => null;\n',
|
||||||
|
};
|
||||||
|
assert.deepEqual(avecArbre(fichiers, releverFrontiere), [
|
||||||
|
'src/geometrie/vue.js:1 import ../moteur/indicateurs.js',
|
||||||
|
'src/geometrie/vue.js:2 import ../csv/lecture.js',
|
||||||
|
'src/geometrie/vue.js:3 import ../demo/prng.js',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("src/geometrie n'importe ni l'interface, ni l'application, ni le stockage, ni un paquet de plateforme", () => {
|
||||||
|
const fichiers = {
|
||||||
|
...MODULES_VIDES,
|
||||||
|
'src/geometrie/vue.js': [
|
||||||
|
"import { x } from '../interface/x.js';",
|
||||||
|
"import { y } from '../application/y.js';",
|
||||||
|
"import { lire } from '../stockage/document.js';",
|
||||||
|
"import { mount } from 'svelte';",
|
||||||
|
"import { writable } from 'svelte/store';",
|
||||||
|
"import { app } from 'electron';",
|
||||||
|
"import { Capacitor } from '@capacitor/core';",
|
||||||
|
"import { tronquer } from './texte.js';",
|
||||||
|
].join('\n'),
|
||||||
|
'src/geometrie/texte.js': 'export const tronquer = (t) => t;\n',
|
||||||
|
};
|
||||||
|
assert.deepEqual(avecArbre(fichiers, releverFrontiere), [
|
||||||
|
'src/geometrie/vue.js:1 import ../interface/x.js',
|
||||||
|
'src/geometrie/vue.js:2 import ../application/y.js',
|
||||||
|
'src/geometrie/vue.js:3 import ../stockage/document.js',
|
||||||
|
'src/geometrie/vue.js:4 import svelte',
|
||||||
|
'src/geometrie/vue.js:5 import svelte/store',
|
||||||
|
'src/geometrie/vue.js:6 import electron',
|
||||||
|
'src/geometrie/vue.js:7 import @capacitor/core',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('src/application peut manquer ; un src/moteur, un src/geometrie, un src/stockage ou un src/csv sans module fait échouer la garde', () => {
|
||||||
assert.deepEqual(avecArbre(MODULES_VIDES, releverFrontiere), []);
|
assert.deepEqual(avecArbre(MODULES_VIDES, releverFrontiere), []);
|
||||||
for (const arbre of ['src/moteur', 'src/stockage', 'src/csv']) {
|
for (const arbre of ['src/moteur', 'src/geometrie', 'src/stockage', 'src/csv']) {
|
||||||
const sansModule = { ...sansArbre(MODULES_VIDES, arbre), [`${arbre}/a.test.js`]: '', 'src/geometrie/echelle.js': '' };
|
const sansModule = { ...sansArbre(MODULES_VIDES, arbre), [`${arbre}/a.test.js`]: '' };
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => avecArbre(sansModule, releverFrontiere),
|
() => avecArbre(sansModule, releverFrontiere),
|
||||||
new RegExp(`${arbre} ne fournit aucun module`),
|
new RegExp(`${arbre} ne fournit aucun module`),
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,41 @@
|
||||||
// téléchargé ; GTT_CHROMIUM désigne un autre exécutable. Sous NixOS, où les
|
// téléchargé ; GTT_CHROMIUM désigne un autre exécutable. Sous NixOS, où les
|
||||||
// binaires téléchargés ne démarrent pas, scripts/installation/shell.nix y
|
// binaires téléchargés ne démarrent pas, scripts/installation/shell.nix y
|
||||||
// met celui de nixpkgs.
|
// met celui de nixpkgs.
|
||||||
export function optionsChromium(env) {
|
//
|
||||||
const chemin = env.GTT_CHROMIUM;
|
// Sous Linux, Chromium convertit le nom d'une entrée de l'OPFS par les
|
||||||
return chemin ? { launchOptions: { executablePath: chemin } } : {};
|
// paramètres régionaux de son processus : hors UTF-8, un nom hors ASCII ne
|
||||||
|
// se convertit pas, et les épreuves du navigateur n'éprouvent plus le
|
||||||
|
// stockage. Les paramètres régionaux qui décident de cette conversion sont
|
||||||
|
// ceux de LC_CTYPE : LC_ALL, puis LC_CTYPE, puis LANG, la première variable
|
||||||
|
// non vide ; aucune, c'est « C ». Quand ils ne sont pas UTF-8, Chromium
|
||||||
|
// reçoit une copie de l'environnement sous LANG=C.UTF-8, LC_ALL et LC_CTYPE
|
||||||
|
// retirés puisqu'ils l'emporteraient sur LANG, comme
|
||||||
|
// scripts/verifier_systemes.sh le lance dans ses conteneurs. Seul le nom des
|
||||||
|
// paramètres régionaux se lit : un nom UTF-8 que l'hôte n'a pas générés
|
||||||
|
// laisse Chromium sous « C », et l'épreuve de l'OPFS du navigateur nomme
|
||||||
|
// alors cette cause. C.UTF-8 vient avec la glibc 2.35 et suivantes, et avec
|
||||||
|
// les distributions qui le livrent avant ; l'environnement reçu reste tel
|
||||||
|
// quel.
|
||||||
|
|
||||||
|
// Un nom de paramètres régionaux dont le jeu de caractères est UTF-8 :
|
||||||
|
// « fr_CA.UTF-8 », « C.utf8 », « de_DE.UTF-8@euro ».
|
||||||
|
const NOM_UTF8 = /\.utf-?8(@[^.]*)?$/i;
|
||||||
|
|
||||||
|
// Paramètres régionaux de LC_CTYPE que env donne, « C » sans aucun.
|
||||||
|
function caracteresDe(env) {
|
||||||
|
for (const variable of ['LC_ALL', 'LC_CTYPE', 'LANG']) {
|
||||||
|
if (env[variable]) return env[variable];
|
||||||
|
}
|
||||||
|
return 'C';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optionsChromium(env) {
|
||||||
|
const lancement = {};
|
||||||
|
if (env.GTT_CHROMIUM) lancement.executablePath = env.GTT_CHROMIUM;
|
||||||
|
if (!NOM_UTF8.test(caracteresDe(env))) {
|
||||||
|
lancement.env = { ...env, LANG: 'C.UTF-8' };
|
||||||
|
delete lancement.env.LC_ALL;
|
||||||
|
delete lancement.env.LC_CTYPE;
|
||||||
|
}
|
||||||
|
return Object.keys(lancement).length > 0 ? { launchOptions: lancement } : {};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,25 @@
|
||||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
// Le Chromium des épreuves du navigateur : celui de Playwright par défaut,
|
// Le Chromium des épreuves du navigateur : celui de Playwright par défaut,
|
||||||
// celui que GTT_CHROMIUM désigne sinon, et vitest.config.js applique la règle.
|
// celui que GTT_CHROMIUM désigne sinon ; sous des paramètres régionaux qui ne
|
||||||
|
// sont pas UTF-8, il reçoit l'environnement sous LANG=C.UTF-8. vitest.config.js
|
||||||
|
// applique la règle.
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import configuration from '../vitest.config.js';
|
import configuration from '../vitest.config.js';
|
||||||
import { optionsChromium } from './chromium.js';
|
import { optionsChromium } from './chromium.js';
|
||||||
import { describe, test } from './lanceur.js';
|
import { describe, test } from './lanceur.js';
|
||||||
|
|
||||||
|
// Un environnement d'épreuve, sous des paramètres régionaux UTF-8.
|
||||||
|
const UTF8 = { PATH: '/usr/bin', LANG: 'fr_CA.UTF-8' };
|
||||||
|
|
||||||
describe('chromium : l’exécutable des épreuves du navigateur', () => {
|
describe('chromium : l’exécutable des épreuves du navigateur', () => {
|
||||||
test('sans GTT_CHROMIUM, Playwright lance le Chromium qu’il a téléchargé', () => {
|
test('sans GTT_CHROMIUM, Playwright lance le Chromium qu’il a téléchargé', () => {
|
||||||
assert.deepEqual(optionsChromium({}), {});
|
assert.deepEqual(optionsChromium(UTF8), {});
|
||||||
assert.deepEqual(optionsChromium({ GTT_CHROMIUM: '' }), {});
|
assert.deepEqual(optionsChromium({ ...UTF8, GTT_CHROMIUM: '' }), {});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('GTT_CHROMIUM désigne l’exécutable que Playwright lance', () => {
|
test('GTT_CHROMIUM désigne l’exécutable que Playwright lance', () => {
|
||||||
assert.deepEqual(optionsChromium({ GTT_CHROMIUM: '/nix/store/abc-chromium/bin/chromium' }), {
|
assert.deepEqual(optionsChromium({ ...UTF8, GTT_CHROMIUM: '/nix/store/abc-chromium/bin/chromium' }), {
|
||||||
launchOptions: { executablePath: '/nix/store/abc-chromium/bin/chromium' },
|
launchOptions: { executablePath: '/nix/store/abc-chromium/bin/chromium' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -25,3 +30,44 @@ describe('chromium : l’exécutable des épreuves du navigateur', () => {
|
||||||
assert.deepEqual(navigateur.test.browser.provider.options, optionsChromium(process.env));
|
assert.deepEqual(navigateur.test.browser.provider.options, optionsChromium(process.env));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('chromium : les paramètres régionaux du navigateur des épreuves', () => {
|
||||||
|
test('des paramètres régionaux UTF-8 laissent Chromium hériter de l’environnement : LC_ALL, puis LC_CTYPE, puis LANG décident', () => {
|
||||||
|
const utf8 = [
|
||||||
|
{ LANG: 'C.UTF-8' },
|
||||||
|
{ LANG: 'C.utf8' },
|
||||||
|
{ LANG: 'de_DE.UTF-8@euro' },
|
||||||
|
{ LANG: 'C', LC_CTYPE: 'fr_CA.utf8' },
|
||||||
|
{ LANG: 'C', LC_CTYPE: 'C', LC_ALL: 'fr_CA.UTF-8' },
|
||||||
|
{ LANG: 'fr_CA.UTF-8', LC_ALL: '', LC_CTYPE: '' },
|
||||||
|
];
|
||||||
|
for (const env of utf8) assert.deepEqual(optionsChromium(env), {}, JSON.stringify(env));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hors UTF-8, Chromium reçoit une copie de l’environnement sous LANG=C.UTF-8, sans LC_ALL ni LC_CTYPE', () => {
|
||||||
|
const cas = [
|
||||||
|
[{}, {}],
|
||||||
|
[{ PATH: '/usr/bin', LC_ALL: 'C' }, { PATH: '/usr/bin' }],
|
||||||
|
[{ PATH: '/usr/bin', LANG: 'POSIX', LC_MESSAGES: 'fr_CA.UTF-8' }, { PATH: '/usr/bin', LC_MESSAGES: 'fr_CA.UTF-8' }],
|
||||||
|
[{ LANG: 'fr_CA.UTF-8', LC_CTYPE: 'C' }, {}],
|
||||||
|
[{ LANG: 'fr_CA.UTF-8', LC_ALL: 'fr_CA.ISO-8859-1' }, {}],
|
||||||
|
[{ LANG: 'C.UTF-8-ancien' }, {}],
|
||||||
|
];
|
||||||
|
for (const [env, reste] of cas) {
|
||||||
|
const avant = { ...env };
|
||||||
|
assert.deepEqual(
|
||||||
|
optionsChromium(env),
|
||||||
|
{ launchOptions: { env: { ...reste, LANG: 'C.UTF-8' } } },
|
||||||
|
JSON.stringify(env),
|
||||||
|
);
|
||||||
|
assert.deepEqual(env, avant, `${JSON.stringify(env)} : l'environnement reçu reste tel quel`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hors UTF-8, GTT_CHROMIUM désigne toujours l’exécutable, que l’environnement accompagne', () => {
|
||||||
|
const env = { LC_ALL: 'C', GTT_CHROMIUM: '/opt/essai/chromium' };
|
||||||
|
assert.deepEqual(optionsChromium(env), {
|
||||||
|
launchOptions: { executablePath: '/opt/essai/chromium', env: { GTT_CHROMIUM: '/opt/essai/chromium', LANG: 'C.UTF-8' } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -57,18 +57,23 @@ function dossierTemporaire() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// test du lanceur, dont chaque épreuve retire à sa fin les dossiers
|
// test du lanceur, dont chaque épreuve retire à sa fin les dossiers
|
||||||
// temporaires qu'elle a ouverts.
|
// temporaires qu'elle a ouverts ; le corps reçoit le contexte de l'épreuve.
|
||||||
function testNettoye(nom, ...suite) {
|
function testNettoye(nom, ...suite) {
|
||||||
const corps = suite.pop();
|
const corps = suite.pop();
|
||||||
test(nom, ...suite, async () => {
|
test(nom, ...suite, async (contexte) => {
|
||||||
try {
|
try {
|
||||||
await corps();
|
await corps(contexte);
|
||||||
} finally {
|
} finally {
|
||||||
for (const dossier of ouverts.splice(0)) rmSync(dossier, { recursive: true, force: true });
|
for (const dossier of ouverts.splice(0)) rmSync(dossier, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ce que dit une commande lancée par spawnSync : l'exception qui l'a
|
||||||
|
// empêchée de démarrer, sinon sa sortie d'erreur. Toujours une chaîne,
|
||||||
|
// seul message que node:assert accepte.
|
||||||
|
const ditPar = (lancee) => String(lancee.error?.message ?? lancee.stderr ?? '');
|
||||||
|
|
||||||
// Charge electron/preload.cjs comme un rendu en bac à sable, contre un
|
// Charge electron/preload.cjs comme un rendu en bac à sable, contre un
|
||||||
// electron dont invoke porte chaque appel au gestionnaire que servir a posé
|
// electron dont invoke porte chaque appel au gestionnaire que servir a posé
|
||||||
// sur ce canal ; arguments et réponse sont copiés comme l'IPC les copie, et
|
// sur ce canal ; arguments et réponse sont copiés comme l'IPC les copie, et
|
||||||
|
|
@ -350,7 +355,14 @@ describe('coquille sur un vrai disque', () => {
|
||||||
testNettoye(
|
testNettoye(
|
||||||
"la lecture par ps (macOS) passe TZ=UTC et LC_ALL=C à la commande : le démarrage inscrit est en temps universel, et un processus d'un autre fuseau lit la séance vivante",
|
"la lecture par ps (macOS) passe TZ=UTC et LC_ALL=C à la commande : le démarrage inscrit est en temps universel, et un processus d'un autre fuseau lit la séance vivante",
|
||||||
{ skip: SOUS_WINDOWS },
|
{ skip: SOUS_WINDOWS },
|
||||||
async () => {
|
async (contexte) => {
|
||||||
|
// L'épreuve joue macOS avec le ps de l'hôte. Sous Linux, la coquille
|
||||||
|
// lit /proc et ne demande pas ps, qu'une image de système minimale ne
|
||||||
|
// porte pas : sans lui, l'épreuve se saute et dit pourquoi.
|
||||||
|
if (spawnSync('ps', ['-p', String(process.pid)]).error?.code === 'ENOENT') {
|
||||||
|
contexte.skip("ps introuvable sur ce système : la lecture du démarrage par ps ne s'y éprouve pas");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const documents = join(dossierTemporaire(), 'Documents');
|
const documents = join(dossierTemporaire(), 'Documents');
|
||||||
mkdirSync(documents, { recursive: true });
|
mkdirSync(documents, { recursive: true });
|
||||||
const fichiers = creerFichiers({
|
const fichiers = creerFichiers({
|
||||||
|
|
@ -366,7 +378,7 @@ describe('coquille sur un vrai disque', () => {
|
||||||
env: { ...process.env, LC_ALL: 'C', TZ: 'UTC' },
|
env: { ...process.env, LC_ALL: 'C', TZ: 'UTC' },
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
});
|
});
|
||||||
assert.equal(enUtc.status, 0, enUtc.stderr);
|
assert.equal(enUtc.status, 0, ditPar(enUtc));
|
||||||
assert.equal(pose.demarrage, enUtc.stdout.trim());
|
assert.equal(pose.demarrage, enUtc.stdout.trim());
|
||||||
|
|
||||||
// Un second processus, dans un fuseau qui n'est pas celui de
|
// Un second processus, dans un fuseau qui n'est pas celui de
|
||||||
|
|
@ -387,7 +399,7 @@ describe('coquille sur un vrai disque', () => {
|
||||||
},
|
},
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
});
|
});
|
||||||
assert.equal(relu.status, 0, relu.stderr);
|
assert.equal(relu.status, 0, ditPar(relu));
|
||||||
assert.deepEqual(JSON.parse(relu.stdout), {
|
assert.deepEqual(JSON.parse(relu.stdout), {
|
||||||
fuseau: autre,
|
fuseau: autre,
|
||||||
tenu: { pris: false, seance: 'seance-a', depuis: pose.depuis, vivant: true },
|
tenu: { pris: false, seance: 'seance-a', depuis: pose.depuis, vivant: true },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue