diff --git a/src/moteur/configuration.js b/src/moteur/configuration.js new file mode 100644 index 0000000..712f1e0 --- /dev/null +++ b/src/moteur/configuration.js @@ -0,0 +1,473 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Passage de la configuration, forme par identifiants, à l'instance indexée +// que consomme le moteur (§ 4, § 5.2), et conversion d'un plan entre ses deux +// formes (§ 8.9). Les formes sont décrites dans types.js. +// +// Trois familles de refus : +// - ErreurConfiguration, avec un code, pour ce qu'une saisie, un import ou un +// plan périmé peut porter : une donnée incohérente avec le reste de la +// configuration, ou une capacité, un nombre de tours, un tour désigné hors +// de son domaine ; +// - TypeError, quand la configuration sort de l'une des formes que garantit +// le code qui la bâtit — stockage, import, interface — et qu'aucune saisie +// ne produit : un identifiant de participant ou de table qui n'est pas un +// entier ≥ 1, une appartenance ni chaîne ni null, un exclu présent et non +// booléen, une portée ni « tous » ni « tour », des contraintes qui ne sont +// pas un objet de quatre booléens, une liste qui n'est pas un tableau. Ces +// formes sont les seules que normaliser contrôle par TypeError ; une valeur +// qui en sort est une faute de l'appelant, qu'aucune valeur par défaut ne +// masque ; +// - RangeError, pour un plan indexé qui n'a pas la forme de l'instance : seul +// le moteur produit cette forme, et l'écart y est une faute de code. +// +// Les champs que normaliser ne lit pas — nom, prénom, numéro de table — ne +// sont pas examinés. Aucune fonction ne modifie ce qu'elle reçoit. +import { ErreurConfiguration } from './erreurs.js'; + +// Statuts d'un participant (§ 4.2), valeurs de instance.statut. +const MOBILE = 0; +const PARTIELLEMENT_FIXE = 1; +const ANCRE = 2; + +// Case de instance.fixe sans table imposée. +const LIBRE = -1; +// Case d'un plan indexé : la réserve. +const RESERVE = -1; +// Case d'un plan indexé qu'indexerPlan n'a pas encore remplie ; jamais rendue. +const NON_PLACE = -2; + +// Plus grand entier que représente un Int32Array : une capacité au-delà s'y +// tronquerait sans bruit. +const ENTIER_32_MAX = 2 ** 31 - 1; + +const NOMS_CONTRAINTES = [ + 'separerAppartenances', + 'nouveauxVoisins', + 'nouvelleTable', + 'varierAppartenances', +]; + +// Valeur citée dans un message : une chaîne s'écrit entre guillemets, pour +// ne pas se lire comme le nombre qu'elle contient. +const decrire = (valeur) => (typeof valeur === 'string' ? JSON.stringify(valeur) : String(valeur)); + +function exigerListe(valeur, nom) { + if (!Array.isArray(valeur)) throw new TypeError(`configuration.${nom} : liste attendue`); +} + +function exigerIdentifiant(id, quoi) { + if (!Number.isInteger(id) || id < 1) { + throw new TypeError(`${quoi} d'identifiant ${decrire(id)} : entier ≥ 1 attendu`); + } +} + +function exigerAppartenance({ id, appartenance }) { + if (appartenance !== null && typeof appartenance !== 'string') { + throw new TypeError( + `participant ${id} : appartenance chaîne ou null attendue, reçu ${decrire(appartenance)}`, + ); + } +} + +// Vrai pour un participant exclu ; un champ exclu absent vaut false. +function estExclu({ id, exclu }) { + if (exclu === undefined) return false; + if (typeof exclu !== 'boolean') { + throw new TypeError(`participant ${id} : exclu booléen attendu, reçu ${decrire(exclu)}`); + } + return exclu; +} + +// Copie des quatre contraintes : une modification ultérieure de la +// configuration n'atteint pas l'instance. +function copierContraintes(contraintes) { + if (typeof contraintes !== 'object' || contraintes === null) { + throw new TypeError('configuration.contraintes : objet attendu'); + } + const copie = {}; + for (const nom of NOMS_CONTRAINTES) { + if (typeof contraintes[nom] !== 'boolean') { + throw new TypeError(`contrainte ${nom} : booléen attendu, reçu ${decrire(contraintes[nom])}`); + } + copie[nom] = contraintes[nom]; + } + return copie; +} + +// Groupe de chaque participant présent, présents triés par identifiant +// croissant : un libellé reçoit le numéro de sa première apparition, −1 +// marque l'absence d'appartenance. L'égalité de chaînes fait le groupe. La +// Map ne sert qu'à retrouver un libellé déjà numéroté. +function numeroterGroupes(presents) { + const groupe = new Int32Array(presents.length); + const groupes = []; + const numeroDe = new Map(); + for (let p = 0; p < presents.length; p += 1) { + const { appartenance } = presents[p]; + if (appartenance === null) { + groupe[p] = -1; + continue; + } + if (!numeroDe.has(appartenance)) { + numeroDe.set(appartenance, groupes.length); + groupes.push(appartenance); + } + groupe[p] = numeroDe.get(appartenance); + } + return { groupe, groupes }; +} + +// Tables imposées par les réservations : fixe[p * R + r] = index de table, +// LIBRE sinon. Chaque réservation est vérifiée dans sa forme — participant et +// table connus, tour dans 1..R —, exclus compris ; celle d'un exclu s'arrête +// là, suspendue, sans effet (§ 4.4). Une réservation qui répète la table déjà +// imposée au même tour ne change rien ; une autre table au même tour lève +// RESERVATION_CONFLIT. +function fixerReservations(reservations, contexte) { + const { connus, indexDe, indexTableDe, idsTables, N, R } = contexte; + const fixe = new Int32Array(N * R).fill(LIBRE); + for (let i = 0; i < reservations.length; i += 1) { + const { participant: id, table: idTable, portee, tour } = reservations[i]; + if (!connus.has(id)) { + throw new ErreurConfiguration('RESERVATION_INCONNUE', { reservation: i, participant: id }); + } + const t = indexTableDe.get(idTable); + if (t === undefined) { + throw new ErreurConfiguration('RESERVATION_INCONNUE', { reservation: i, table: idTable }); + } + // Tours couverts, numérotés à partir de 0 : chacun des R tours pour la + // portée « tous », le tour désigné pour la portée « tour ». + let premier; + let dernier; + if (portee === 'tous') { + premier = 0; + dernier = R - 1; + } else if (portee === 'tour') { + if (!Number.isInteger(tour) || tour < 1 || tour > R) { + throw new ErreurConfiguration('RESERVATION_TOUR', { reservation: i, tour }); + } + premier = tour - 1; + dernier = tour - 1; + } else { + throw new TypeError( + `réservation ${i} : portée ${decrire(portee)}, « tous » ou « tour » attendue`, + ); + } + const p = indexDe.get(id); + if (p === undefined) continue; + for (let r = premier; r <= dernier; r += 1) { + const imposee = fixe[p * R + r]; + if (imposee === LIBRE) { + fixe[p * R + r] = t; + } else if (imposee !== t) { + throw new ErreurConfiguration('RESERVATION_CONFLIT', { + participant: id, + tour: r + 1, + tables: [idsTables[imposee], idTable], + }); + } + } + } + return fixe; +} + +// Lève SURRESERVATION quand, à un tour, une table reçoit plus de personnes +// fixées que de sièges (§ 5.9). Le compte porte sur les personnes, lues dans +// fixe : une réservation répétée ne compte qu'une fois. Les tables se +// parcourent par index, chacune par tours croissants. +function verifierSurreservation(fixe, { N, T, R, capacite, idsTables }) { + const fixees = new Int32Array(T * R); + for (let p = 0; p < N; p += 1) { + for (let r = 0; r < R; r += 1) { + const t = fixe[p * R + r]; + if (t !== LIBRE) fixees[t * R + r] += 1; + } + } + for (let t = 0; t < T; t += 1) { + for (let r = 0; r < R; r += 1) { + if (fixees[t * R + r] > capacite[t]) { + throw new ErreurConfiguration('SURRESERVATION', { + table: idsTables[t], + tour: r + 1, + reservees: fixees[t * R + r], + capacite: capacite[t], + }); + } + } + } +} + +// Statut de chaque participant, dérivé de fixe et non de la forme des +// réservations (§ 4.2) : ancré quand la même table est imposée à chacun des R +// tours, mobile quand aucune ne l'est, partiellement fixé sinon. Un ancré +// compte dans k et dans ancresParTable ; un partiellement fixé compte parmi +// les mobiles. +function deriverStatuts(fixe, N, T, R) { + const statut = new Uint8Array(N); + const ancresParTable = new Int32Array(T); + let k = 0; + for (let p = 0; p < N; p += 1) { + const premiere = fixe[p * R]; + let memeTable = true; + let aucune = true; + for (let r = 0; r < R; r += 1) { + const t = fixe[p * R + r]; + if (t !== premiere) memeTable = false; + if (t !== LIBRE) aucune = false; + } + if (aucune) { + statut[p] = MOBILE; + } else if (memeTable) { + statut[p] = ANCRE; + ancresParTable[premiere] += 1; + k += 1; + } else { + statut[p] = PARTIELLEMENT_FIXE; + } + } + return { statut, ancresParTable, k }; +} + +/** + * Instance indexée d'une configuration (§ 4, § 5.2). Les participants exclus + * en sont absents (§ 4.4) : ni index ni groupe, et leurs réservations, + * suspendues, ne fixent rien et ne prennent aucun siège. + * + * Lève ErreurConfiguration sur la première règle enfreinte — participants, + * puis tables, nombre de tours, réservations une à une, surréservation : + * PARTICIPANT_DOUBLON, TABLE_DOUBLON, CAPACITE (non entière, < 2, ou au-delà + * d'un Int32Array), TOURS (R non entier ou < 1), RESERVATION_INCONNUE + * (participant ou table), RESERVATION_TOUR (tour absent ou hors de 1..R), + * RESERVATION_CONFLIT (une personne à deux tables d'un même tour), + * SURRESERVATION (plus de personnes fixées que de sièges à une table, à un + * tour). Une salle trop petite n'est pas refusée : nombrePlacesManquantes la + * chiffre, et la recherche la refuse (§ 5.9). Les détails désignent + * participants et tables par identifiant, les tours par leur numéro à partir + * de 1, et la réservation fautive par son rang dans + * configuration.reservations, à partir de 0. + * + * Lève TypeError sur les seules formes qu'énumère l'en-tête du module : + * identifiants de participant et de table, appartenance, exclu, portée, + * contraintes, listes. Une capacité, un nombre de tours ou un tour désigné + * hors de leur forme lèvent CAPACITE, TOURS ou RESERVATION_TOUR ; une + * réservation qui désigne un participant ou une table absents de la + * configuration lève RESERVATION_INCONNUE, quelle que soit la forme de la + * valeur qui les désigne. + * + * @param {import('./types.js').Configuration} configuration + * @returns {import('./types.js').Instance} + */ +export function normaliser(configuration) { + const { participants, tables, tours: R, reservations } = configuration; + exigerListe(participants, 'participants'); + exigerListe(tables, 'tables'); + exigerListe(reservations, 'reservations'); + const contraintes = copierContraintes(configuration.contraintes); + + // connus porte tous les identifiants, exclus compris : un doublon reste un + // doublon, et la réservation d'un exclu n'est pas inconnue. + const connus = new Set(); + const presents = []; + const exclus = []; + for (const participant of participants) { + exigerIdentifiant(participant.id, 'participant'); + if (connus.has(participant.id)) { + throw new ErreurConfiguration('PARTICIPANT_DOUBLON', { participant: participant.id }); + } + connus.add(participant.id); + exigerAppartenance(participant); + if (estExclu(participant)) exclus.push(participant.id); + else presents.push(participant); + } + presents.sort((a, b) => a.id - b.id); + exclus.sort((a, b) => a - b); + + const T = tables.length; + const idsTables = []; + const indexTableDe = new Map(); + const capacite = new Int32Array(T); + for (let t = 0; t < T; t += 1) { + const { id, capacite: places } = tables[t]; + exigerIdentifiant(id, 'table'); + if (indexTableDe.has(id)) throw new ErreurConfiguration('TABLE_DOUBLON', { table: id }); + if (!Number.isInteger(places) || places < 2 || places > ENTIER_32_MAX) { + throw new ErreurConfiguration('CAPACITE', { table: id, capacite: places }); + } + idsTables.push(id); + indexTableDe.set(id, t); + capacite[t] = places; + } + + if (!Number.isInteger(R) || R < 1) throw new ErreurConfiguration('TOURS', { tours: R }); + + const N = presents.length; + const ids = presents.map((participant) => participant.id); + const indexDe = new Map(ids.map((id, p) => [id, p])); + const { groupe, groupes } = numeroterGroupes(presents); + const fixe = fixerReservations(reservations, { connus, indexDe, indexTableDe, idsTables, N, R }); + verifierSurreservation(fixe, { N, T, R, capacite, idsTables }); + const { statut, ancresParTable, k } = deriverStatuts(fixe, N, T, R); + + return { + N, + T, + R, + ids, + indexDe, + exclus: new Set(exclus), + idsTables, + indexTableDe, + capacite, + groupe, + groupes, + fixe, + statut, + ancresParTable, + k, + n: N - k, + contraintes, + }; +} + +/** + * Plan indexé d'un plan par identifiants : tableDe[p * R + r] = index de + * table, −1 pour la réserve. plan.tables déclare chaque table de l'instance + * une fois, et chaque liste se rattache à sa table par l'identifiant déclaré + * à la même position ; l'ordre des tables et celui des ids dans une liste + * sont indifférents. Un plan accepté revient donc de planDepuisIndex à + * l'identique, à ces deux ordres près. + * + * La forme indexée n'a pas de case pour un participant absent d'un tour, + * placé deux fois ou étranger à l'instance, ni pour une table que le plan ne + * déclare pas : au lieu de perdre l'écart, indexerPlan lève + * ErreurConfiguration — PLAN_TOURS (tours ou réserves en nombre ≠ R), + * PLAN_TABLE_INCONNUE, PLAN_TABLE_DOUBLON, PLAN_TABLE_ABSENTE (une table de + * l'instance que plan.tables ne déclare pas), PLAN_LISTES (un tour sans + * exactement une liste par table déclarée), PLAN_INCONNU, PLAN_EXCLU_PLACE + * (participant exclu de l'instance), PLAN_DOUBLE_PLACE (deux fois dans un + * tour, réserve comprise), PLAN_NON_ASSIS (ni à une table ni en réserve à un + * tour). Les capacités et les réservations ne sont pas examinées. + * + * @param {import('./types.js').Instance} instance + * @param {import('./types.js').Plan} plan + * @returns {Int32Array} + */ +export function indexerPlan(instance, plan) { + const { N, T, R, ids, idsTables, indexDe, indexTableDe, exclus } = instance; + if (plan.tours.length !== R || plan.reserves.length !== R) { + throw new ErreurConfiguration('PLAN_TOURS', { + attendu: R, + tours: plan.tours.length, + reserves: plan.reserves.length, + }); + } + // Index de table de chaque liste, par position dans plan.tables. + const tableDeListe = []; + const declarees = new Set(); + for (const id of plan.tables) { + const t = indexTableDe.get(id); + if (t === undefined) throw new ErreurConfiguration('PLAN_TABLE_INCONNUE', { table: id }); + if (declarees.has(id)) throw new ErreurConfiguration('PLAN_TABLE_DOUBLON', { table: id }); + declarees.add(id); + tableDeListe.push(t); + } + // Sans id inconnu ni doublon, une table manque exactement quand le plan en + // déclare moins que l'instance ; la première absente, dans l'ordre de + // l'instance, est nommée. + if (tableDeListe.length !== T) { + const absente = idsTables.find((id) => !declarees.has(id)); + throw new ErreurConfiguration('PLAN_TABLE_ABSENTE', { table: absente }); + } + + const tableDe = new Int32Array(N * R).fill(NON_PLACE); + const placer = (id, r, t) => { + const p = indexDe.get(id); + if (p === undefined) { + const code = exclus.has(id) ? 'PLAN_EXCLU_PLACE' : 'PLAN_INCONNU'; + throw new ErreurConfiguration(code, { participant: id, tour: r + 1 }); + } + if (tableDe[p * R + r] !== NON_PLACE) { + throw new ErreurConfiguration('PLAN_DOUBLE_PLACE', { participant: id, tour: r + 1 }); + } + tableDe[p * R + r] = t; + }; + for (let r = 0; r < R; r += 1) { + const listes = plan.tours[r]; + if (listes.length !== tableDeListe.length) { + throw new ErreurConfiguration('PLAN_LISTES', { + tour: r + 1, + listes: listes.length, + tables: tableDeListe.length, + }); + } + for (let i = 0; i < listes.length; i += 1) { + for (const id of listes[i]) placer(id, r, tableDeListe[i]); + } + for (const id of plan.reserves[r]) placer(id, r, RESERVE); + for (let p = 0; p < N; p += 1) { + if (tableDe[p * R + r] === NON_PLACE) { + throw new ErreurConfiguration('PLAN_NON_ASSIS', { participant: ids[p], tour: r + 1 }); + } + } + } + return tableDe; +} + +/** + * Plan par identifiants d'un plan indexé : les tables dans l'ordre de + * l'instance, chaque liste de table et chaque réserve en ids croissants + * (§ 8.9). Lève RangeError quand tableDe n'a pas N × R cases, ou qu'une case + * n'est ni −1 ni un index de table. + * + * @param {import('./types.js').Instance} instance + * @param {ArrayLike} tableDe + * @returns {import('./types.js').Plan} + */ +export function planDepuisIndex(instance, tableDe) { + const { N, T, R, ids, idsTables } = instance; + if (tableDe.length !== N * R) { + throw new RangeError(`plan indexé de ${tableDe.length} cases, N × R = ${N * R} attendues`); + } + const tours = []; + const reserves = []; + for (let r = 0; r < R; r += 1) { + const listes = Array.from({ length: T }, () => []); + const reserve = []; + // Les ids croissent avec l'index : parcourir les participants par index + // range chaque liste en ids croissants, sans tri. + for (let p = 0; p < N; p += 1) { + const t = tableDe[p * R + r]; + if (t === RESERVE) { + reserve.push(ids[p]); + } else if (Number.isInteger(t) && t >= 0 && t < T) { + listes[t].push(ids[p]); + } else { + throw new RangeError( + `plan indexé : participant ${ids[p]}, tour ${r + 1}, ` + + `index de table ${decrire(t)} hors de −1..${T - 1}`, + ); + } + } + tours.push(listes); + reserves.push(reserve); + } + return { tables: [...idsTables], tours, reserves }; +} + +/** + * Places qui manquent à chaque tour pour asseoir tous les participants : + * max(0, N − Σ c_t). La quantité égale max(0, n − Σ (c_t − a_t)) et ne + * dépend d'aucune réservation (§ 5.9) : un ancré retire une personne des + * mobiles et un siège de la capacité libre, un partiellement fixé reste + * parmi les mobiles et ne retire rien. + * + * @param {import('./types.js').Instance} instance + * @returns {number} + */ +export function nombrePlacesManquantes(instance) { + let places = 0; + for (let t = 0; t < instance.T; t += 1) places += instance.capacite[t]; + return Math.max(0, instance.N - places); +} diff --git a/src/moteur/configuration.test.js b/src/moteur/configuration.test.js new file mode 100644 index 0000000..e1e2f35 --- /dev/null +++ b/src/moteur/configuration.test.js @@ -0,0 +1,764 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves de la configuration (§ 4, § 5.2, § 5.9) : l'instance indexée que +// rend normaliser et ses refus, le nombre de places manquantes, la conversion +// d'un plan entre la forme par identifiants et la forme indexée. Les +// identifiants diffèrent des index qu'ils reçoivent et des tables arrivent +// hors de l'ordre de leurs identifiants : confondre un identifiant et un index +// fait échouer une épreuve au lieu de passer inaperçu. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { ErreurAnnulee, ErreurConfiguration } from './erreurs.js'; +import { + indexerPlan, + nombrePlacesManquantes, + normaliser, + planDepuisIndex, +} from './configuration.js'; + +const SANS_CONTRAINTE = { + separerAppartenances: false, + nouveauxVoisins: false, + nouvelleTable: false, + varierAppartenances: false, +}; + +// Entiers de debut à fin, inclus. +const suite = (debut, fin) => Array.from({ length: fin - debut + 1 }, (_, i) => debut + i); + +// Participants des identifiants donnés, sans appartenance. +const personnes = (ids) => ids.map((id) => ({ id, nom: `P${id}`, appartenance: null })); +const exclu = (id) => ({ id, nom: `P${id}`, appartenance: null, exclu: true }); + +// Tables des identifiants donnés, toutes de la même capacité, numérotées dans +// l'ordre fourni. +const tablesDe = (ids, capacite) => ids.map((id, i) => ({ id, numero: i + 1, capacite })); + +const tous = (participant, table) => ({ participant, table, portee: 'tous' }); +const auTour = (participant, table, tour) => ({ participant, table, portee: 'tour', tour }); + +// Libellé d'une valeur de cas : une chaîne se distingue du nombre qu'elle écrit. +const libelleDe = (valeur) => (typeof valeur === 'string' ? `« ${valeur} » en texte` : String(valeur)); + +const configuration = ({ + participants, + tables, + tours, + reservations = [], + contraintes = SANS_CONTRAINTE, +}) => ({ participants, tables, tours, reservations, contraintes }); + +// Gèle une valeur et tout ce qu'elle contient : une écriture y lève +// TypeError, le module s'exécutant en mode strict. +function geler(valeur) { + if (typeof valeur === 'object' && valeur !== null) { + for (const enfant of Object.values(valeur)) geler(enfant); + Object.freeze(valeur); + } + return valeur; +} + +// Écart entre ce que lève fonction et une ErreurConfiguration du code et des +// détails attendus ; null quand ils concordent. +function ecartDeRefus(fonction, code, details) { + try { + fonction(); + } catch (erreur) { + if (!(erreur instanceof ErreurConfiguration)) { + return `${erreur?.name} au lieu d'une ErreurConfiguration : ${erreur?.message}`; + } + if (erreur.code !== code) return `code ${erreur.code} au lieu de ${code}`; + try { + assert.deepEqual(erreur.details, details); + } catch { + return `détails ${JSON.stringify(erreur.details)} au lieu de ${JSON.stringify(details)}`; + } + return null; + } + return 'aucun refus'; +} + +// Chaque cas [libellé, fonction, code, détails] dont le refus s'écarte de +// l'attendu, en une ligne lisible. +function ecartsDeRefus(cas) { + assert.ok(cas.length > 0, 'aucun cas examiné'); + return cas + .map(([libelle, fonction, code, details]) => [libelle, ecartDeRefus(fonction, code, details)]) + .filter(([, ecart]) => ecart !== null) + .map(([libelle, ecart]) => `${libelle} : ${ecart}`); +} + +describe('normaliser : statuts (§ 4.2, § 4.3)', () => { + test('le statut se dérive du fait : ancré, partiellement fixé ou mobile', () => { + const instance = normaliser(configuration({ + participants: personnes(suite(1, 9)), + tables: tablesDe([11, 12, 13, 14], 4), + tours: 3, + reservations: [ + tous(1, 11), + auTour(2, 12, 3), auTour(2, 12, 1), auTour(2, 12, 2), + auTour(3, 13, 2), + auTour(4, 11, 1), auTour(4, 12, 2), + auTour(5, 13, 1), auTour(5, 13, 2), auTour(5, 14, 3), + auTour(7, 14, 1), + auTour(8, 14, 3), + auTour(9, 12, 1), auTour(9, 13, 2), auTour(9, 12, 3), + ], + })); + // 1 : portée « tous » ; 2 : R tours désignés, même table ; 3 : le seul + // tour du milieu ; 4 : deux tours, deux tables ; 5 : chaque tour, deux + // tables ; 6 : aucune réservation ; 7 : le seul premier tour ; 8 : le + // seul dernier tour ; 9 : chaque tour, la même table au premier et au + // dernier, une autre entre les deux. Seuls 1 et 2 comptent dans k : un + // partiellement fixé compte parmi les mobiles. + assert.deepEqual(instance.statut, Uint8Array.from([2, 2, 1, 1, 1, 0, 1, 1, 1])); + assert.deepEqual(instance.fixe, Int32Array.from([ + 0, 0, 0, + 1, 1, 1, + -1, 2, -1, + 0, 1, -1, + 2, 2, 3, + -1, -1, -1, + 3, -1, -1, + -1, -1, 3, + 1, 2, 1, + ])); + assert.equal(instance.k, 2); + assert.equal(instance.n, 7); + assert.deepEqual(instance.ancresParTable, Int32Array.from([1, 1, 0, 0])); + + // À R = 1, une réservation de tour désigné fixe chacun des R tours : ancré. + const unTour = normaliser(configuration({ + participants: personnes([1, 2]), + tables: tablesDe([11, 12], 2), + tours: 1, + reservations: [auTour(1, 11, 1)], + })); + assert.deepEqual(unTour.statut, Uint8Array.from([2, 0])); + assert.deepEqual(unTour.fixe, Int32Array.from([0, -1])); + assert.equal(unTour.k, 1); + }); +}); + +describe('normaliser : ancrés et instance réduite (§ 5.2)', () => { + test('k, n = N − k et ancresParTable sur quatre tables de 5 à un ancré chacune', () => { + const instance = normaliser(configuration({ + participants: personnes(suite(1, 20)), + tables: tablesDe([11, 12, 13, 14], 5), + tours: 5, + reservations: [ + tous(1, 11), + tous(6, 12), + ...suite(1, 5).map((tour) => auTour(11, 13, tour)), + tous(16, 14), + // Partiellement fixé : compté parmi les mobiles. + auTour(2, 12, 3), + ], + })); + assert.equal(instance.N, 20); + assert.equal(instance.T, 4); + assert.equal(instance.R, 5); + assert.equal(instance.k, 4); + assert.equal(instance.n, 16); + assert.deepEqual(instance.ancresParTable, Int32Array.from([1, 1, 1, 1])); + assert.equal(instance.statut[instance.indexDe.get(2)], 1); + }); + + test('ancresParTable suit les index de table, dans l’ordre de configuration.tables', () => { + const instance = normaliser(configuration({ + participants: personnes(suite(1, 8)), + tables: tablesDe([13, 11, 14, 12], 5), + tours: 2, + reservations: [tous(1, 12), tous(2, 13), tous(3, 12), tous(4, 14)], + })); + assert.deepEqual(instance.idsTables, [13, 11, 14, 12]); + assert.equal(instance.indexTableDe.get(12), 3); + assert.deepEqual(instance.capacite, Int32Array.from([5, 5, 5, 5])); + assert.deepEqual(instance.ancresParTable, Int32Array.from([1, 0, 1, 2])); + assert.equal(instance.k, 4); + }); +}); + +describe('normaliser : exclusion (§ 4.4)', () => { + test('un exclu disparaît de ids, figure dans exclus, et sa réservation est suspendue', () => { + const instance = normaliser(configuration({ + participants: [...personnes([1, 2]), exclu(3), ...personnes([4])], + tables: tablesDe([11, 12], 2), + tours: 2, + reservations: [tous(3, 12)], + })); + assert.equal(instance.N, 3); + assert.deepEqual(instance.ids, [1, 2, 4]); + assert.equal(instance.indexDe.has(3), false); + assert.deepEqual([...instance.exclus], [3]); + assert.equal(instance.k, 0); + assert.equal(instance.n, 3); + assert.deepEqual(instance.ancresParTable, Int32Array.from([0, 0])); + assert.deepEqual(instance.fixe, new Int32Array(6).fill(-1)); + assert.deepEqual(instance.statut, new Uint8Array(3)); + }); + + test('la réservation d’un exclu rend sa place : elle n’entre pas dans la surréservation', () => { + const participants = [...personnes([1, 2]), exclu(3)]; + const avec = (liste) => configuration({ + participants: liste, + tables: tablesDe([11, 12], 2), + tours: 2, + reservations: [tous(1, 11), tous(2, 11), tous(3, 11)], + }); + const instance = normaliser(avec(participants)); + assert.equal(instance.k, 2); + assert.deepEqual(instance.ancresParTable, Int32Array.from([2, 0])); + // Réintégré, le même participant fait déborder la table. + const reintegres = participants.map((participant) => ({ ...participant, exclu: false })); + assert.deepEqual( + ecartsDeRefus([[ + 'réintégration', + () => normaliser(avec(reintegres)), + 'SURRESERVATION', + { table: 11, tour: 1, reservees: 3, capacite: 2 }, + ]]), + [], + ); + }); + + test('la réservation suspendue d’un exclu n’interrompt pas la lecture : celles qui la suivent s’appliquent', () => { + // En tête de liste, la réservation de l'exclu précède celles des présents : + // un saut qui sortirait de la boucle au lieu de passer à la suivante les + // perdrait toutes, sans bruit. + const instance = normaliser(configuration({ + participants: [...personnes([1]), exclu(2)], + tables: tablesDe([11, 12], 2), + tours: 2, + reservations: [tous(2, 11), tous(1, 12)], + })); + assert.equal(instance.k, 1); + assert.deepEqual(instance.statut, Uint8Array.from([2])); + assert.deepEqual(instance.fixe, Int32Array.from([1, 1])); + assert.deepEqual( + ecartsDeRefus([[ + 'trois présents réservés à une table de 2, après l’exclu', + () => normaliser(configuration({ + participants: [...personnes([1, 2, 3]), exclu(4)], + tables: tablesDe([11, 12], 2), + tours: 1, + reservations: [tous(4, 11), tous(1, 11), tous(2, 11), tous(3, 11)], + })), + 'SURRESERVATION', + { table: 11, tour: 1, reservees: 3, capacite: 2 }, + ]]), + [], + ); + }); + + test('une réservation suspendue reste vérifiée dans sa forme, et n’a aucun effet', () => { + const avec = (reservations) => configuration({ + participants: [...personnes([1, 2]), exclu(3)], + tables: tablesDe([11, 12], 2), + tours: 2, + reservations, + }); + // Deux tables au même tour : sans effet, donc sans conflit. + assert.equal(normaliser(avec([tous(3, 11), auTour(3, 12, 1)])).k, 0); + assert.deepEqual( + ecartsDeRefus([ + ['table inconnue', () => normaliser(avec([tous(3, 99)])), + 'RESERVATION_INCONNUE', { reservation: 0, table: 99 }], + ['tour hors de 1..R', () => normaliser(avec([auTour(3, 11, 3)])), + 'RESERVATION_TOUR', { reservation: 0, tour: 3 }], + ]), + [], + ); + }); +}); + +describe('normaliser : appartenances', () => { + test('les groupes se numérotent par première apparition en ids croissants, quel que soit l’ordre fourni', () => { + const fournis = [ + { id: 9, nom: 'P9', appartenance: 'Y' }, + { id: 2, nom: 'P2', appartenance: 'Z' }, + { id: 5, nom: 'P5', appartenance: 'X' }, + { id: 7, nom: 'P7', appartenance: null }, + { id: 3, nom: 'P3', appartenance: 'W', exclu: true }, + { id: 4, nom: 'P4', appartenance: 'Z' }, + { id: 1, nom: 'P1', appartenance: 'Y' }, + ]; + const ordres = [fournis, [...fournis].reverse(), [3, 6, 0, 5, 1, 4, 2].map((i) => fournis[i])]; + assert.ok(ordres.length > 0, 'aucun ordre examiné'); + for (const participants of ordres) { + const instance = normaliser(configuration({ participants, tables: tablesDe([11], 8), tours: 1 })); + assert.deepEqual(instance.ids, [1, 2, 4, 5, 7, 9]); + assert.deepEqual(instance.groupes, ['Y', 'Z', 'X']); + assert.deepEqual(instance.groupe, Int32Array.from([0, 1, 1, 2, -1, 0])); + } + }); + + test('l’égalité de chaînes fait le groupe : normaliser ne réconcilie rien', () => { + // La chaîne vide est une appartenance comme une autre : seul null marque + // l'absence d'appartenance. + const participants = [ + { id: 1, nom: 'P1', appartenance: 'X' }, + { id: 2, nom: 'P2', appartenance: 'x' }, + { id: 3, nom: 'P3', appartenance: 'X ' }, + { id: 4, nom: 'P4', appartenance: 'X' }, + { id: 5, nom: 'P5', appartenance: '' }, + { id: 6, nom: 'P6', appartenance: null }, + { id: 7, nom: 'P7', appartenance: '' }, + ]; + const instance = normaliser(configuration({ participants, tables: tablesDe([11], 8), tours: 1 })); + assert.deepEqual(instance.groupes, ['X', 'x', 'X ', '']); + assert.deepEqual(instance.groupe, Int32Array.from([0, 1, 2, 0, 3, -1, 3])); + }); +}); + +describe('normaliser : la configuration reçue', () => { + test('normaliser ne modifie pas la configuration reçue', () => { + const recue = geler(configuration({ + participants: [...personnes([9, 2]), exclu(5), { id: 4, nom: 'P4', appartenance: 'X' }], + tables: tablesDe([30, 10], 4), + tours: 2, + reservations: [tous(9, 30), auTour(2, 10, 2)], + })); + const instance = normaliser(recue); + assert.deepEqual(instance.ids, [2, 4, 9]); + assert.deepEqual(recue.participants.map((participant) => participant.id), [9, 2, 5, 4]); + }); + + test('l’instance copie les contraintes au lieu de les partager', () => { + const contraintes = { ...SANS_CONTRAINTE, separerAppartenances: true }; + const instance = normaliser(configuration({ + participants: personnes([1, 2]), + tables: tablesDe([11], 2), + tours: 1, + contraintes, + })); + contraintes.separerAppartenances = false; + assert.deepEqual(instance.contraintes, { ...SANS_CONTRAINTE, separerAppartenances: true }); + }); +}); + +describe('normaliser : refus (§ 5.9, § 6.2)', () => { + // Trois participants, deux tables de 2, deux tours : chaque cas n'enfreint + // qu'une règle. + const base = configuration({ + participants: personnes([1, 2, 3]), + tables: tablesDe([11, 12], 2), + tours: 2, + }); + const avec = (modification) => () => normaliser({ ...base, ...modification }); + const capacite12 = (capacite) => ({ tables: [...tablesDe([11], 2), { id: 12, numero: 2, capacite }] }); + + test('chaque code d’erreur, sur un cas', () => { + const cas = [ + ['un participant en double', avec({ participants: personnes([1, 2, 2]) }), + 'PARTICIPANT_DOUBLON', { participant: 2 }], + ['un participant en double, dont un exclu', avec({ participants: [...personnes([1, 2]), exclu(2)] }), + 'PARTICIPANT_DOUBLON', { participant: 2 }], + ['une table en double', avec({ tables: tablesDe([11, 12, 11], 2) }), + 'TABLE_DOUBLON', { table: 11 }], + ...[1, 0, -3, 2.5, '8', Number.NaN, 2 ** 31].map((capacite) => [ + `une capacité ${libelleDe(capacite)}`, avec(capacite12(capacite)), + 'CAPACITE', { table: 12, capacite }, + ]), + ...[0, -1, 1.5, '2', Number.NaN].map((tours) => [ + `${libelleDe(tours)} tours`, avec({ tours }), 'TOURS', { tours }, + ]), + // Le détail reservation est le rang dans configuration.reservations, à + // partir de 0 : la réservation fautive se place au rang 0, puis au rang 1. + // Un participant ou une table désignés par une valeur qui n'est pas un + // identifiant de la configuration sont inconnus, quelle qu'en soit la + // forme. + ['un participant inconnu', avec({ reservations: [tous(9, 11)] }), + 'RESERVATION_INCONNUE', { reservation: 0, participant: 9 }], + ['un participant inconnu au rang 1', avec({ reservations: [tous(1, 11), tous(9, 11)] }), + 'RESERVATION_INCONNUE', { reservation: 1, participant: 9 }], + ['un participant désigné en texte', avec({ reservations: [tous('1', 11)] }), + 'RESERVATION_INCONNUE', { reservation: 0, participant: '1' }], + ['une table inconnue', avec({ reservations: [tous(1, 11), auTour(2, 99, 1)] }), + 'RESERVATION_INCONNUE', { reservation: 1, table: 99 }], + ['une table désignée en texte', avec({ reservations: [tous(1, '11')] }), + 'RESERVATION_INCONNUE', { reservation: 0, table: '11' }], + ['un tour désigné absent', avec({ reservations: [{ participant: 1, table: 11, portee: 'tour' }] }), + 'RESERVATION_TOUR', { reservation: 0, tour: undefined }], + ...[0, 3, 1.5, '1'].map((tour) => [ + `le tour désigné ${libelleDe(tour)}`, avec({ reservations: [auTour(1, 11, tour)] }), + 'RESERVATION_TOUR', { reservation: 0, tour }, + ]), + ['le tour désigné 3 au rang 1', avec({ reservations: [tous(1, 11), auTour(2, 12, 3)] }), + 'RESERVATION_TOUR', { reservation: 1, tour: 3 }], + ['deux tables au même tour désigné', avec({ reservations: [auTour(1, 11, 1), auTour(1, 12, 1)] }), + 'RESERVATION_CONFLIT', { participant: 1, tour: 1, tables: [11, 12] }], + ['la portée « tous » et un tour désigné ailleurs', avec({ reservations: [tous(1, 12), auTour(1, 11, 2)] }), + 'RESERVATION_CONFLIT', { participant: 1, tour: 2, tables: [12, 11] }], + // Le conflit se découvre aussi en traitant une portée « tous » : elle ne + // remplace jamais une table déjà imposée à l'un de ses tours. + ['un tour désigné puis la portée « tous » ailleurs', avec({ reservations: [auTour(1, 11, 2), tous(1, 12)] }), + 'RESERVATION_CONFLIT', { participant: 1, tour: 2, tables: [11, 12] }], + ['deux portées « tous » à deux tables', avec({ reservations: [tous(1, 11), tous(1, 12)] }), + 'RESERVATION_CONFLIT', { participant: 1, tour: 1, tables: [11, 12] }], + ['trois réservés à une table de 2 au tour 2', + avec({ reservations: [tous(1, 11), tous(2, 11), auTour(3, 11, 2)] }), + 'SURRESERVATION', { table: 11, tour: 2, reservees: 3, capacite: 2 }], + ]; + assert.deepEqual(ecartsDeRefus(cas), []); + }); + + test('une table entièrement gelée, k_t = c_t, est acceptée', () => { + const instance = normaliser({ ...base, reservations: [tous(1, 11), tous(2, 11)] }); + assert.deepEqual(instance.ancresParTable, Int32Array.from([2, 0])); + }); + + test('une réservation redondante compte une personne, pas deux', () => { + const instance = normaliser({ + ...base, + reservations: [tous(1, 11), auTour(1, 11, 2), tous(1, 11), tous(2, 11)], + }); + assert.equal(instance.k, 2); + assert.deepEqual(instance.ancresParTable, Int32Array.from([2, 0])); + }); + + test('SURRESERVATION se juge à chaque table et à chaque tour, contre la capacité de cette table', () => { + // Trois tables hors de l'ordre de leurs identifiants, de capacités + // distinctes, et T ≠ R. Cinq participants : la plus grande table, pleine + // plus un, les fixe tous, le dernier compris. + const tables = [ + { id: 13, numero: 1, capacite: 3 }, + { id: 11, numero: 2, capacite: 2 }, + { id: 12, numero: 3, capacite: 4 }, + ]; + const tours = 2; + const fixer = (reservations) => () => + normaliser(configuration({ participants: personnes(suite(1, 5)), tables, tours, reservations })); + // c_t + 1 personnes fixées à la table t, au tour r ou à chaque tour par la + // portée « tous » : refusé, au premier tour qui déborde. + const debordees = tables.flatMap(({ id, capacite }) => [ + ...suite(1, tours).map((tour) => [ + `${capacite + 1} fixées à la table ${id} au tour ${tour}`, + fixer(suite(1, capacite + 1).map((p) => auTour(p, id, tour))), + 'SURRESERVATION', { table: id, tour, reservees: capacite + 1, capacite }, + ]), + [`${capacite + 1} ancrées à la table ${id}`, + fixer(suite(1, capacite + 1).map((p) => tous(p, id))), + 'SURRESERVATION', { table: id, tour: 1, reservees: capacite + 1, capacite }], + ]); + assert.deepEqual(ecartsDeRefus(debordees), []); + // c_t personnes fixées remplissent la table sans la déborder : accepté. + const remplies = tables.flatMap(({ id, capacite }) => [ + ...suite(1, tours).map((tour) => [ + `${capacite} fixées à la table ${id} au tour ${tour}`, + fixer(suite(1, capacite).map((p) => auTour(p, id, tour))), + ]), + [`${capacite} ancrées à la table ${id}`, fixer(suite(1, capacite).map((p) => tous(p, id)))], + ]); + assert.ok(remplies.length > 0, 'aucun cas examiné'); + const refusees = []; + for (const [libelle, fonction] of remplies) { + try { + fonction(); + } catch (erreur) { + refusees.push(`${libelle} : ${erreur?.message}`); + } + } + assert.deepEqual(refusees, []); + }); + + test('une capacité de 2³¹ − 1, le plus grand entier d’un Int32Array, est acceptée', () => { + const instance = normaliser({ ...base, ...capacite12(2 ** 31 - 1) }); + assert.equal(instance.capacite[1], 2 ** 31 - 1); + }); + + test('une valeur hors du contrat de données lève TypeError', () => { + const cas = [ + ['identifiant de participant 0', { participants: personnes([0, 1]) }], + ['identifiant de participant 1.5', { participants: personnes([1.5]) }], + ['identifiant de participant en texte', { participants: personnes(['1']) }], + ['identifiant de table 0', { tables: tablesDe([0, 12], 2) }], + ['identifiant de table en texte', { tables: tablesDe(['11', 12], 2) }], + ['appartenance absente', { participants: [{ id: 1, nom: 'P1' }] }], + ['appartenance numérique', { participants: [{ id: 1, nom: 'P1', appartenance: 7 }] }], + ...['oui', '', 0, null].map((valeur) => [ + `exclusion ${libelleDe(valeur)}`, + { participants: [{ id: 1, nom: 'P1', appartenance: null, exclu: valeur }] }, + ]), + ['portée inconnue', { reservations: [{ participant: 1, table: 11, portee: 'chaque' }] }], + ['contraintes absentes', { contraintes: undefined }], + ['une contrainte absente', { + contraintes: { separerAppartenances: true, nouveauxVoisins: false, nouvelleTable: false }, + }], + ['une contrainte non booléenne', { contraintes: { ...SANS_CONTRAINTE, nouvelleTable: 1 } }], + // Une liste absente, puis une valeur qui n'est pas un tableau mais que le + // code parcourrait sans bruit : un ensemble s'itère, et un objet sans + // longueur ne fait tourner aucune boucle. + ['participants absents', { participants: undefined }], + ['participants en ensemble', { participants: new Set(personnes([1, 2])) }], + ['tables absentes', { tables: undefined }], + ['tables en objet', { tables: {} }], + ['réservations absentes', { reservations: undefined }], + ['réservations en objet', { reservations: {} }], + ]; + const ecarts = []; + for (const [libelle, modification] of cas) { + try { + normaliser({ ...base, ...modification }); + ecarts.push(`${libelle} : aucun refus`); + } catch (erreur) { + if (!(erreur instanceof TypeError)) { + ecarts.push(`${libelle} : ${erreur?.name} au lieu de TypeError`); + } + } + } + assert.ok(cas.length > 0, 'aucun cas examiné'); + assert.deepEqual(ecarts, []); + }); +}); + +describe('nombrePlacesManquantes (§ 5.9)', () => { + const salle = (reservations = [], participants = personnes(suite(1, 15))) => + normaliser(configuration({ participants, tables: tablesDe([11, 12, 13], 4), tours: 2, reservations })); + + // Places manquantes lues sur l'instance réduite du § 5.2 : n − Σ (c_t − a_t), + // l'opposé de l'écart Σ (c_t − k_t) − n du § 5.9. + function placesManquantesReduites(instance) { + let placesLibres = 0; + for (let t = 0; t < instance.T; t += 1) { + placesLibres += instance.capacite[t] - instance.ancresParTable[t]; + } + return instance.n - placesLibres; + } + + test('vaut 3 pour trois tables de 4 et quinze personnes, que normaliser accepte', () => { + assert.equal(nombrePlacesManquantes(salle()), 3); + }); + + test('ne change pas quand on ajoute ou déplace une réservation', () => { + const variantes = [ + [], + [tous(1, 11)], + [tous(1, 12)], + [auTour(1, 11, 1)], + [auTour(1, 11, 1), auTour(1, 12, 2)], + [tous(1, 11), auTour(2, 12, 2), tous(3, 13), tous(4, 13)], + ]; + assert.ok(variantes.length > 0, 'aucune variante examinée'); + for (const reservations of variantes) { + const instance = salle(reservations); + assert.equal(nombrePlacesManquantes(instance), 3, JSON.stringify(reservations)); + assert.equal(placesManquantesReduites(instance), 3, JSON.stringify(reservations)); + } + }); + + test('vaut 0, jamais un nombre négatif, quand la salle suffit', () => { + assert.equal(nombrePlacesManquantes(salle([], personnes(suite(1, 12)))), 0); + assert.equal(nombrePlacesManquantes(salle([], personnes(suite(1, 10)))), 0); + }); + + test('un exclu ne compte pas : sa réintégration fait manquer une place', () => { + const participants = [...personnes(suite(1, 12)), exclu(13)]; + assert.equal(nombrePlacesManquantes(salle([], participants)), 0); + const reintegres = participants.map((participant) => ({ ...participant, exclu: false })); + assert.equal(nombrePlacesManquantes(salle([], reintegres)), 1); + }); +}); + +describe('plans : forme par identifiants et forme indexée (§ 8.9)', () => { + // Quatre participants placés, fournis dans le désordre, un exclu ; trois + // tables hors de l'ordre de leurs identifiants ; deux tours. T ≠ R : la + // disposition p × R + r se distingue de p × T + r, et la longueur N × R de + // N × T. + function instancePlans() { + return normaliser(configuration({ + participants: [...personnes([3, 8]), exclu(5), ...personnes([11, 6])], + tables: tablesDe([40, 20, 30], 2), + tours: 2, + })); + } + + // tableDe[p × R + r], p dans l'ordre des ids [3, 6, 8, 11], et le plan par + // identifiants qui lui correspond. Chaque table reçoit quelqu'un, une table + // reste vide à un tour, et une liste de table comme une réserve portent + // deux ids, dont l'ordre croissant se voit. + const TABLE_DE = [ + -1, 0, + -1, 1, + 0, 2, + 2, 1, + ]; + const PLAN = { + tables: [40, 20, 30], + tours: [[[8], [], [11]], [[3], [6, 11], [8]]], + reserves: [[3, 6], []], + }; + + test('planDepuisIndex rend des ids croissants par table et dans la réserve', () => { + const instance = instancePlans(); + const plan = planDepuisIndex(instance, Int32Array.from(TABLE_DE)); + assert.deepEqual(plan, PLAN); + // Une liste ordinaire, gelée, convient aussi et reste intacte. + assert.deepEqual(planDepuisIndex(instance, geler([...TABLE_DE])), PLAN); + const listes = [...plan.tours.flat(), ...plan.reserves]; + assert.ok(listes.length > 0, 'aucune liste examinée'); + for (const liste of listes) { + assert.deepEqual(liste, [...liste].sort((a, b) => a - b)); + } + // Le plan rendu a sa propre liste de tables : la modifier laisse + // l'instance intacte. + plan.tables.reverse(); + assert.deepEqual(instance.idsTables, [40, 20, 30]); + }); + + test('indexerPlan ∘ planDepuisIndex est l’identité sur chaque plan indexé de l’instance', () => { + const instance = instancePlans(); + const cases = instance.N * instance.R; + // Chaque case prend une valeur de −1 (la réserve) à T − 1. + const valeurs = instance.T + 1; + let examines = 0; + for (let rang = 0; rang < valeurs ** cases; rang += 1) { + const tableDe = new Int32Array(cases); + for (let i = 0, reste = rang; i < cases; i += 1, reste = Math.floor(reste / valeurs)) { + tableDe[i] = (reste % valeurs) - 1; + } + const retour = indexerPlan(instance, planDepuisIndex(instance, tableDe)); + if (retour.length !== tableDe.length || retour.some((valeur, i) => valeur !== tableDe[i])) { + assert.deepEqual(retour, tableDe); + } + examines += 1; + } + assert.equal(examines, 4 ** 8); + }); + + test('indexerPlan lit chaque table par son identifiant, dans l’ordre que le plan déclare', () => { + const instance = instancePlans(); + assert.deepEqual(indexerPlan(instance, geler(structuredClone(PLAN))), Int32Array.from(TABLE_DE)); + // Le même plan, ses tables déclarées dans un autre ordre, chaque liste et + // chaque réserve dans le désordre. + const permute = { + tables: [30, 40, 20], + tours: [[[11], [8], []], [[8], [3], [11, 6]]], + reserves: [[6, 3], []], + }; + const tableDe = indexerPlan(instance, geler(permute)); + assert.deepEqual(tableDe, Int32Array.from(TABLE_DE)); + assert.deepEqual(planDepuisIndex(instance, tableDe), PLAN); + }); + + test('indexerPlan refuse ce que la forme indexée ne représente pas', () => { + const instance = instancePlans(); + const variante = (modifier) => () => { + const plan = structuredClone(PLAN); + modifier(plan); + return indexerPlan(instance, plan); + }; + const cas = [ + ['un participant inconnu', variante((plan) => plan.tours[0][0].push(99)), + 'PLAN_INCONNU', { participant: 99, tour: 1 }], + ['un participant inconnu en réserve', variante((plan) => plan.reserves[1].push(99)), + 'PLAN_INCONNU', { participant: 99, tour: 2 }], + ['un participant exclu', variante((plan) => plan.tours[1][1].unshift(5)), + 'PLAN_EXCLU_PLACE', { participant: 5, tour: 2 }], + ['un participant exclu en réserve', variante((plan) => plan.reserves[0].push(5)), + 'PLAN_EXCLU_PLACE', { participant: 5, tour: 1 }], + ['une table inconnue', variante((plan) => { plan.tables[1] = 50; }), + 'PLAN_TABLE_INCONNUE', { table: 50 }], + ['une table en double', variante((plan) => { plan.tables[1] = 30; }), + 'PLAN_TABLE_DOUBLON', { table: 30 }], + // La table 30 retirée du plan, ses occupants passés en réserve : le plan + // reste cohérent avec lui-même, mais ne reprend plus chaque table de + // l'instance. + ['une table non déclarée', variante((plan) => { + plan.tables.pop(); + plan.tours.forEach((listes, r) => plan.reserves[r].push(...listes.pop())); + }), 'PLAN_TABLE_ABSENTE', { table: 30 }], + // Aucune table déclarée, chacun en réserve : des trois absentes, la + // première dans l'ordre de l'instance est nommée. + ['aucune table déclarée', variante((plan) => { + plan.tables = []; + plan.tours = [[], []]; + plan.reserves = [[3, 6, 8, 11], [3, 6, 8, 11]]; + }), 'PLAN_TABLE_ABSENTE', { table: 40 }], + ['un tour de moins', variante((plan) => plan.tours.pop()), + 'PLAN_TOURS', { attendu: 2, tours: 1, reserves: 2 }], + ['un tour de trop', variante((plan) => plan.tours.push([[], [], []])), + 'PLAN_TOURS', { attendu: 2, tours: 3, reserves: 2 }], + ['une réserve de trop', variante((plan) => plan.reserves.push([])), + 'PLAN_TOURS', { attendu: 2, tours: 2, reserves: 3 }], + ['une liste de table de trop', variante((plan) => plan.tours[1].push([])), + 'PLAN_LISTES', { tour: 2, listes: 4, tables: 3 }], + ['une liste de table de moins', variante((plan) => plan.tours[1].pop()), + 'PLAN_LISTES', { tour: 2, listes: 2, tables: 3 }], + ['assis et en réserve', variante((plan) => plan.reserves[0].unshift(8)), + 'PLAN_DOUBLE_PLACE', { participant: 8, tour: 1 }], + ['deux fois à une table', variante((plan) => plan.tours[1][1].push(6)), + 'PLAN_DOUBLE_PLACE', { participant: 6, tour: 2 }], + ['à deux tables d’un même tour', variante((plan) => plan.tours[0][1].push(8)), + 'PLAN_DOUBLE_PLACE', { participant: 8, tour: 1 }], + ['deux fois en réserve', variante((plan) => plan.reserves[0].push(3)), + 'PLAN_DOUBLE_PLACE', { participant: 3, tour: 1 }], + ['absent d’un tour', variante((plan) => plan.reserves[0].pop()), + 'PLAN_NON_ASSIS', { participant: 6, tour: 1 }], + ]; + assert.deepEqual(ecartsDeRefus(cas), []); + }); + + test('indexerPlan refuse quiconque manque à un tour : chaque participant, à chaque tour', () => { + const instance = instancePlans(); + // Chaque id de l'instance, du premier index au dernier, retiré de la + // liste qui le porte à ce tour, table ou réserve. + const cas = [3, 6, 8, 11].flatMap((id) => [1, 2].map((tour) => [ + `${id} retiré du tour ${tour}`, + () => { + const plan = structuredClone(PLAN); + for (const liste of [...plan.tours[tour - 1], plan.reserves[tour - 1]]) { + if (liste.includes(id)) liste.splice(liste.indexOf(id), 1); + } + return indexerPlan(instance, plan); + }, + 'PLAN_NON_ASSIS', + { participant: id, tour }, + ])); + assert.deepEqual(ecartsDeRefus(cas), []); + }); + + test('planDepuisIndex refuse un plan indexé hors de la forme de l’instance', () => { + const instance = instancePlans(); + const variantes = [ + ['longueur N × R − 1', TABLE_DE.slice(1)], + ['longueur N × R + 1', [...TABLE_DE, -1]], + ['longueur N × T', Array.from({ length: instance.N * instance.T }, () => -1)], + ['index de table T', TABLE_DE.map((valeur, i) => (i === 0 ? instance.T : valeur))], + ['index −2', TABLE_DE.map((valeur, i) => (i === 3 ? -2 : valeur))], + ['index non entier', TABLE_DE.map((valeur, i) => (i === 5 ? 0.5 : valeur))], + ]; + assert.ok(variantes.length > 0, 'aucune variante examinée'); + const ecarts = []; + for (const [libelle, tableDe] of variantes) { + try { + planDepuisIndex(instance, tableDe); + ecarts.push(`${libelle} : aucun refus`); + } catch (erreur) { + if (!(erreur instanceof RangeError)) { + ecarts.push(`${libelle} : ${erreur?.name} au lieu de RangeError`); + } + } + } + assert.deepEqual(ecarts, []); + }); +}); + +describe('erreurs du moteur', () => { + test('ErreurConfiguration porte son code et ses détails', () => { + const erreur = new ErreurConfiguration('CAPACITE', { table: 12, capacite: 1 }); + assert.ok(erreur instanceof Error); + assert.equal(erreur.name, 'ErreurConfiguration'); + assert.equal(erreur.code, 'CAPACITE'); + assert.deepEqual(erreur.details, { table: 12, capacite: 1 }); + assert.match(erreur.message, /CAPACITE/); + assert.deepEqual(new ErreurConfiguration('TOURS').details, {}); + }); + + test('ErreurAnnulee est une Error qui porte son nom', () => { + const erreur = new ErreurAnnulee(); + assert.ok(erreur instanceof Error); + assert.equal(erreur.name, 'ErreurAnnulee'); + }); +}); diff --git a/src/moteur/erreurs.js b/src/moteur/erreurs.js new file mode 100644 index 0000000..dc4e2fb --- /dev/null +++ b/src/moteur/erreurs.js @@ -0,0 +1,28 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Erreurs que lève le moteur. Le code d'une ErreurConfiguration nomme la +// règle enfreinte ; ses détails désignent participants et tables par +// identifiant, les tours par leur numéro à partir de 1, et une réservation, +// qui n'a pas d'identifiant, par son rang à partir de 0 dans la liste qui la +// porte. L'appelant lit le code et les détails ; le message, code et détails +// en JSON, sert au journal et n'est pas un texte affiché, que fournit la +// table des libellés (§ 14.6). + +export class ErreurConfiguration extends Error { + /** + * @param {string} code + * @param {Object} [details] + */ + constructor(code, details = {}) { + super(`${code} ${JSON.stringify(details)}`); + this.code = code; + this.details = details; + } +} +ErreurConfiguration.prototype.name = 'ErreurConfiguration'; + +// Levée par une recherche interrompue à la demande : aucun résultat partiel +// ne l'accompagne. +export class ErreurAnnulee extends Error {} +ErreurAnnulee.prototype.name = 'ErreurAnnulee'; diff --git a/src/moteur/types.js b/src/moteur/types.js new file mode 100644 index 0000000..e10cb14 --- /dev/null +++ b/src/moteur/types.js @@ -0,0 +1,73 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Contrat de données du moteur : les formes qu'il reçoit et celles qu'il +// rend. Ce module ne porte que des définitions JSDoc et n'exécute rien ; un +// module cite un type par import('./types.js').Instance. + +/** + * @typedef {Object} Participant + * @property {number} id entier ≥ 1, unique, jamais réutilisé + * @property {string} nom + * @property {string} [prenom] + * @property {string|null} appartenance null = sans appartenance ; l'égalité + * de chaînes fait le groupe (la + * réconciliation est l'affaire du CSV) + * @property {boolean} [exclu] absent = false + * + * @typedef {Object} Table + * @property {number} id entier ≥ 1, unique + * @property {number} numero affiché, ≥ 1 + * @property {number} capacite entier ≥ 2, défaut déjà résolu + * + * @typedef {Object} Reservation + * @property {number} participant id + * @property {number} table id + * @property {'tous'|'tour'} portee + * @property {number} [tour] 1..R, requis quand portee === 'tour' + * + * @typedef {Object} Contraintes + * @property {boolean} separerAppartenances + * @property {boolean} nouveauxVoisins + * @property {boolean} nouvelleTable + * @property {boolean} varierAppartenances + * + * @typedef {Object} Configuration + * @property {Participant[]} participants + * @property {Table[]} tables + * @property {number} tours R ≥ 1 + * @property {Reservation[]} reservations + * @property {Contraintes} contraintes + * + * @typedef {Object} Plan forme par identifiants (§ 8.9) + * @property {number[]} tables ids de table, dans l'ordre des listes + * @property {number[][][]} tours tours[r][i] = ids assis à tables[i], croissants + * @property {number[][]} reserves reserves[r] = ids assis nulle part au tour r + * + * @typedef {Object} Instance forme indexée, consommée par le moteur + * @property {number} N participants non exclus + * @property {number} T + * @property {number} R + * @property {number[]} ids index → id participant, ordre croissant + * @property {Map} indexDe id → index + * @property {Set} exclus ids exclus (pour le vérificateur) + * @property {number[]} idsTables index → id table, ordre de configuration.tables + * @property {Map} indexTableDe + * @property {Int32Array} capacite par index de table + * @property {Int32Array} groupe par index participant ; −1 sans appartenance + * @property {string[]} groupes index de groupe → libellé ; ordre de première + * apparition en parcourant les ids croissants + * @property {Int32Array} fixe [p * R + r] → index de table imposé (r à + * partir de 0), −1 si libre + * @property {Uint8Array} statut 0 mobile, 1 partiellement fixé, 2 ancré (§ 4.2) + * @property {Int32Array} ancresParTable a_t + * @property {number} k ancrés + * @property {number} n N − k + * @property {Contraintes} contraintes + * + * Un plan indexé est un Int32Array `tableDe` de longueur N × R : + * tableDe[p * R + r] = index de table, −1 pour la réserve. + * + * L'ordre canonique des tableaux par personne est celui de `instance.ids` : + * tout tableau rendu par le moteur et indexé par personne suit cet ordre. + */