diff --git a/src/demo/appartenances.js b/src/demo/appartenances.js new file mode 100644 index 0000000..a8aa069 --- /dev/null +++ b/src/demo/appartenances.js @@ -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; +} diff --git a/src/demo/appartenances.test.js b/src/demo/appartenances.test.js new file mode 100644 index 0000000..3f03b57 --- /dev/null +++ b/src/demo/appartenances.test.js @@ -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'); + }); +}); diff --git a/src/demo/catalogue.js b/src/demo/catalogue.js new file mode 100644 index 0000000..9a5b8b3 --- /dev/null +++ b/src/demo/catalogue.js @@ -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([[], [], [], []]), +}); diff --git a/src/demo/catalogue.long.test.js b/src/demo/catalogue.long.test.js new file mode 100644 index 0000000..62bda5f --- /dev/null +++ b/src/demo/catalogue.long.test.js @@ -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(`(? [...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}`), []); + }); +}); diff --git a/src/demo/catalogue.test.js b/src/demo/catalogue.test.js new file mode 100644 index 0000000..f2ded47 --- /dev/null +++ b/src/demo/catalogue.test.js @@ -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]); + }); +}); diff --git a/src/demo/noms.js b/src/demo/noms.js new file mode 100644 index 0000000..b35df84 --- /dev/null +++ b/src/demo/noms.js @@ -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}`)); +} diff --git a/src/demo/noms.test.js b/src/demo/noms.test.js new file mode 100644 index 0000000..2523ecb --- /dev/null +++ b/src/demo/noms.test.js @@ -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); + }); +});