From 518ef93f676e0726c5e1fce6bd4e9cfbeb2a24fe Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 5 Oct 2026 17:27:06 -0400 Subject: [PATCH] [ADD] engine: configuration normalised into an indexed instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine works on dense arrays, not on the operator's objects. normaliser checks the configuration, derives anchored, partially fixed and mobile statuses from the reservations themselves, and suspends an excluded person's reservations without stopping on them. It never refuses a room that is too small: that is a property of the configuration, which the diagnosis names and the search refuses (§ 5.9). A plan converts both ways between ids and indices, seats in ascending id. Checked: a reservation after an excluded one, and a conflict found under « tous », each fail their mutant; an independent oracle agreed throughout. --- FR --- [ADD] moteur : configuration normalisée en une instance indexée Le moteur travaille sur des tableaux denses, pas sur les objets de l'opérateur. normaliser vérifie la configuration, dérive les statuts ancré, partiellement fixé et mobile des réservations elles-mêmes, et suspend les réservations d'un exclu sans s'y arrêter. Il ne refuse jamais une salle trop petite : c'est une propriété de la configuration, que le diagnostic nomme et que la recherche refuse (§ 5.9). Un plan passe dans les deux sens des ids aux index, sièges par id croissant. Vérifié : une réservation après celle d'un exclu, et un conflit découvert sous « tous », font chacune tomber leur mutant ; un oracle indépendant concorde partout. Assisted-by: Claude Opus 5.5 --- src/moteur/configuration.js | 473 +++++++++++++++++++ src/moteur/configuration.test.js | 764 +++++++++++++++++++++++++++++++ src/moteur/erreurs.js | 28 ++ src/moteur/types.js | 73 +++ 4 files changed, 1338 insertions(+) create mode 100644 src/moteur/configuration.js create mode 100644 src/moteur/configuration.test.js create mode 100644 src/moteur/erreurs.js create mode 100644 src/moteur/types.js 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. + */