[ADD] demo: four seeded configurations, forged names, witness round

The demonstrations are built from a seed in a fixed draw order. The large
one seats 260 at 29 tables of 8 and 4 of 7 with one facilitator each, its
variant at 33 tables of 8; the small one seats 12 at four tables of 3 in
groups 4/4/4, its conflict variant 5/4/3. Group sizes follow the weighted
law, a seed with no witness round is refused, and forged names appear
nowhere else in the repository.

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

--- FR ---

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

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

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

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-05 17:27:34 -04:00
parent 926a3ff21f
commit a2a3a7eca8
7 changed files with 1614 additions and 0 deletions

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

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

View file

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

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

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

View file

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

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

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

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

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

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

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