[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:
parent
926a3ff21f
commit
a2a3a7eca8
7 changed files with 1614 additions and 0 deletions
123
src/demo/appartenances.js
Normal file
123
src/demo/appartenances.js
Normal 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;
|
||||
}
|
||||
269
src/demo/appartenances.test.js
Normal file
269
src/demo/appartenances.test.js
Normal 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
269
src/demo/catalogue.js
Normal 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([[], [], [], []]),
|
||||
});
|
||||
235
src/demo/catalogue.long.test.js
Normal file
235
src/demo/catalogue.long.test.js
Normal 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
481
src/demo/catalogue.test.js
Normal 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
81
src/demo/noms.js
Normal 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
156
src/demo/noms.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue