830 lines
38 KiB
JavaScript
830 lines
38 KiB
JavaScript
|
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
|||
|
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
|||
|
|
|
|||
|
|
// Épreuves des deux vérificateurs du moteur (§ 14.12), par mutation : chaque
|
|||
|
|
// épreuve part d'un état qui tient, et chaque mutation y produit la violation
|
|||
|
|
// annoncée et aucune autre. Un vérificateur qui rend toujours la liste vide
|
|||
|
|
// échoue à chacune.
|
|||
|
|
//
|
|||
|
|
// verifierInvariants part du plan parfait de la petite démonstration
|
|||
|
|
// (§ 15.3), recopié ci-dessous. La configuration ajoute à la démonstration ce
|
|||
|
|
// que deux codes demandent : un participant exclu, 13, dont la réservation
|
|||
|
|
// est suspendue (§ 4.4), et une réservation de tour désigné que le plan
|
|||
|
|
// honore, 6 à la table de numéro 1 au tour 2. Les épreuves désignent
|
|||
|
|
// participants et tables par leur numéro ; participant() et table() en
|
|||
|
|
// donnent l'identifiant. Les participants de numéro 1 à 13 portent les
|
|||
|
|
// identifiants 107 à 191, de 7 en 7 : ni contigus, ni égaux à leur index plus
|
|||
|
|
// un. Les tables de numéro 1 à 4 portent les identifiants 21 à 24, que
|
|||
|
|
// l'instance range dans l'ordre 23, 21, 24, 22 et le plan dans l'ordre des
|
|||
|
|
// numéros. Confondre identifiant, numéro, index et position, d'un participant
|
|||
|
|
// comme d'une table, fait échouer une épreuve au lieu de passer inaperçu.
|
|||
|
|
//
|
|||
|
|
// verifierIndicateurs part des chiffres de ce même plan : N = 12, et 8 pour
|
|||
|
|
// chacun en plafond a priori, en plafond réalisé et en rencontres (§ 15.3).
|
|||
|
|
import assert from 'node:assert/strict';
|
|||
|
|
import { describe, test } from '../../test/lanceur.js';
|
|||
|
|
import { indexerPlan, normaliser } from './configuration.js';
|
|||
|
|
import { ErreurConfiguration } from './erreurs.js';
|
|||
|
|
import { verifierIndicateurs, verifierInvariants } from './verification.js';
|
|||
|
|
|
|||
|
|
const SANS_CONTRAINTE = {
|
|||
|
|
separerAppartenances: false,
|
|||
|
|
nouveauxVoisins: false,
|
|||
|
|
nouvelleTable: false,
|
|||
|
|
varierAppartenances: false,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Identifiant du participant de numéro donné.
|
|||
|
|
const participant = (numero) => 100 + 7 * numero;
|
|||
|
|
// Identifiants des participants de numéros donnés, dans l'ordre donné.
|
|||
|
|
const participants = (...numeros) => numeros.map(participant);
|
|||
|
|
// Identifiant de la table de numéro donné.
|
|||
|
|
const table = (numero) => 20 + numero;
|
|||
|
|
// Identifiant qui n'est celui d'aucun participant, exclu compris, ni d'aucune
|
|||
|
|
// table.
|
|||
|
|
const ETRANGER = 99;
|
|||
|
|
|
|||
|
|
// Numéros des membres de chaque appartenance de la petite démonstration
|
|||
|
|
// (§ 15.3).
|
|||
|
|
const MEMBRES = [
|
|||
|
|
['A', [1, 5, 8, 12]],
|
|||
|
|
['B', [2, 4, 9, 11]],
|
|||
|
|
['C', [3, 6, 7, 10]],
|
|||
|
|
];
|
|||
|
|
const appartenanceDe = (numero) => MEMBRES.find(([, numeros]) => numeros.includes(numero))[0];
|
|||
|
|
|
|||
|
|
const RESERVATION_HONOREE = { participant: participant(6), table: table(1), portee: 'tour', tour: 2 };
|
|||
|
|
const RESERVATION_SUSPENDUE = { participant: participant(13), table: table(2), portee: 'tous' };
|
|||
|
|
|
|||
|
|
function configuration(reservations = [RESERVATION_HONOREE, RESERVATION_SUSPENDUE]) {
|
|||
|
|
const membres = Array.from({ length: 12 }, (_, i) => ({
|
|||
|
|
id: participant(i + 1),
|
|||
|
|
nom: `P${i + 1}`,
|
|||
|
|
appartenance: appartenanceDe(i + 1),
|
|||
|
|
}));
|
|||
|
|
return {
|
|||
|
|
participants: [...membres, { id: participant(13), nom: 'P13', appartenance: null, exclu: true }],
|
|||
|
|
tables: [3, 1, 4, 2].map((numero) => ({ id: table(numero), numero, capacite: 3 })),
|
|||
|
|
tours: 4,
|
|||
|
|
reservations,
|
|||
|
|
contraintes: SANS_CONTRAINTE,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Le plan parfait de la petite démonstration (§ 15.3), écrit en numéros : à
|
|||
|
|
// chaque tour, les tables de numéro 1 à 4, chacune réunissant un membre de
|
|||
|
|
// chaque appartenance ; personne en réserve. Gelé, comme chaque plan que les
|
|||
|
|
// épreuves passent à verifierInvariants : une écriture du vérificateur y lève
|
|||
|
|
// TypeError, quelle que soit la branche qui la fait.
|
|||
|
|
const PLAN_PARFAIT = gelerPlan({
|
|||
|
|
tables: [table(1), table(2), table(3), table(4)],
|
|||
|
|
tours: [
|
|||
|
|
[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]],
|
|||
|
|
[[6, 9, 12], [3, 8, 11], [2, 5, 10], [1, 4, 7]],
|
|||
|
|
[[4, 8, 10], [2, 7, 12], [1, 6, 11], [3, 5, 9]],
|
|||
|
|
[[5, 7, 11], [1, 9, 10], [3, 4, 12], [2, 6, 8]],
|
|||
|
|
].map((listes) => listes.map((numeros) => participants(...numeros))),
|
|||
|
|
reserves: [[], [], [], []],
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const INSTANCE = normaliser(configuration());
|
|||
|
|
|
|||
|
|
// Copie du plan parfait, mutée par modifier puis gelée.
|
|||
|
|
function planMute(modifier) {
|
|||
|
|
const plan = structuredClone(PLAN_PARFAIT);
|
|||
|
|
modifier(plan);
|
|||
|
|
return gelerPlan(plan);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Violations du plan parfait, une fois que modifier a muté une copie.
|
|||
|
|
function apresMutation(modifier, options) {
|
|||
|
|
return verifierInvariants(INSTANCE, planMute(modifier), options);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Retire id de la liste de table qui le porte.
|
|||
|
|
function retirer(listes, id) {
|
|||
|
|
for (const liste of listes) {
|
|||
|
|
if (liste.includes(id)) liste.splice(liste.indexOf(id), 1);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Chaque cas [libellé, calcul, attendu] dont le résultat diffère de
|
|||
|
|
// l'attendu, en une ligne lisible ; une exception est un écart.
|
|||
|
|
function ecarts(cas) {
|
|||
|
|
assert.ok(cas.length > 0, 'aucun cas examiné');
|
|||
|
|
return cas.flatMap(([libelle, calcul, attendu]) => {
|
|||
|
|
let obtenu;
|
|||
|
|
try {
|
|||
|
|
obtenu = calcul();
|
|||
|
|
} catch (erreur) {
|
|||
|
|
return [`${libelle} : ${erreur?.name} ${erreur?.message}`];
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
assert.deepEqual(obtenu, attendu);
|
|||
|
|
return [];
|
|||
|
|
} catch {
|
|||
|
|
return [`${libelle} : ${JSON.stringify(obtenu)}`];
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Gèle un plan et chacune de ses listes, puis le rend : une écriture faite
|
|||
|
|
// depuis un module ES, toujours en mode strict, y lève TypeError.
|
|||
|
|
function gelerPlan(plan) {
|
|||
|
|
const geler = (valeur) => {
|
|||
|
|
if (Array.isArray(valeur)) valeur.forEach(geler);
|
|||
|
|
return Object.freeze(valeur);
|
|||
|
|
};
|
|||
|
|
geler(plan.tables);
|
|||
|
|
geler(plan.tours);
|
|||
|
|
geler(plan.reserves);
|
|||
|
|
return Object.freeze(plan);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const CODES = [
|
|||
|
|
'TOURS',
|
|||
|
|
'TABLE_INCONNUE',
|
|||
|
|
'INCONNU',
|
|||
|
|
'EXCLU_PLACE',
|
|||
|
|
'DOUBLE_PLACE',
|
|||
|
|
'NON_ASSIS',
|
|||
|
|
'CAPACITE',
|
|||
|
|
'RESERVATION',
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// [libellé, mutation du plan parfait, options, violations attendues]. Chaque
|
|||
|
|
// code a au moins une mutation qui le produit seul.
|
|||
|
|
const MUTATIONS = [
|
|||
|
|
['un tour de moins', (plan) => {
|
|||
|
|
plan.tours.pop();
|
|||
|
|
plan.reserves.pop();
|
|||
|
|
}, undefined, [{ code: 'TOURS' }]],
|
|||
|
|
['un tour de trop', (plan) => {
|
|||
|
|
plan.tours.push(structuredClone(plan.tours[0]));
|
|||
|
|
plan.reserves.push([]);
|
|||
|
|
}, undefined, [{ code: 'TOURS' }]],
|
|||
|
|
['une réserve de moins', (plan) => {
|
|||
|
|
plan.reserves.pop();
|
|||
|
|
}, undefined, [{ code: 'TOURS' }]],
|
|||
|
|
|
|||
|
|
['une table inconnue, vide à chaque tour', (plan) => {
|
|||
|
|
plan.tables.push(ETRANGER);
|
|||
|
|
plan.tours.forEach((listes) => listes.push([]));
|
|||
|
|
}, undefined, [{ code: 'TABLE_INCONNUE', table: ETRANGER, detail: 'inconnue' }]],
|
|||
|
|
['une table inconnue déclarée deux fois, vide à chaque tour', (plan) => {
|
|||
|
|
plan.tables.push(ETRANGER, ETRANGER);
|
|||
|
|
plan.tours.forEach((listes) => listes.push([], []));
|
|||
|
|
}, undefined, [{ code: 'TABLE_INCONNUE', table: ETRANGER, detail: 'inconnue' }]],
|
|||
|
|
['une table déclarée deux fois, vide à sa seconde position', (plan) => {
|
|||
|
|
plan.tables.push(table(3));
|
|||
|
|
plan.tours.forEach((listes) => listes.push([]));
|
|||
|
|
}, undefined, [{ code: 'TABLE_INCONNUE', table: table(3), detail: 'doublon' }]],
|
|||
|
|
// La table de numéro 1 déclarée une seconde fois. Au tour 1, sa seconde
|
|||
|
|
// liste reçoit ses trois convives et 4 : quatre entrées, qu'aucune capacité
|
|||
|
|
// ne borne. Au tour 2, elle reçoit 6, réservé à la table de numéro 1 : la
|
|||
|
|
// réservation ne s'y honore pas.
|
|||
|
|
['une table déclarée deux fois, sa seconde liste peuplée', (plan) => {
|
|||
|
|
plan.tables.push(table(1));
|
|||
|
|
plan.tours.forEach((listes) => listes.push([]));
|
|||
|
|
retirer(plan.tours[0], participant(4));
|
|||
|
|
plan.tours[0][4] = [...plan.tours[0][0], participant(4)];
|
|||
|
|
plan.tours[0][0] = [];
|
|||
|
|
retirer(plan.tours[1], participant(6));
|
|||
|
|
plan.tours[1][4].push(participant(6));
|
|||
|
|
}, undefined, [
|
|||
|
|
{ code: 'TABLE_INCONNUE', table: table(1), detail: 'doublon' },
|
|||
|
|
{ code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 },
|
|||
|
|
]],
|
|||
|
|
['une table déclarée à la place d’une autre', (plan) => {
|
|||
|
|
plan.tables[1] = table(1);
|
|||
|
|
}, undefined, [
|
|||
|
|
{ code: 'TABLE_INCONNUE', table: table(1), detail: 'doublon' },
|
|||
|
|
{ code: 'TABLE_INCONNUE', table: table(2), detail: 'absente' },
|
|||
|
|
]],
|
|||
|
|
['une table non déclarée, ses occupants en réserve permise', (plan) => {
|
|||
|
|
plan.tables.pop();
|
|||
|
|
plan.tours.forEach((listes, r) => plan.reserves[r].push(...listes.pop()));
|
|||
|
|
}, { reserveAutorisee: true }, [{ code: 'TABLE_INCONNUE', table: table(4), detail: 'absente' }]],
|
|||
|
|
['une liste de trop au tour 3', (plan) => {
|
|||
|
|
plan.tours[2].push([]);
|
|||
|
|
}, undefined, [{ code: 'TABLE_INCONNUE', tour: 3, detail: 'listes' }]],
|
|||
|
|
['une liste de moins au tour 3, ses occupants en réserve permise', (plan) => {
|
|||
|
|
plan.reserves[2].push(...plan.tours[2].pop());
|
|||
|
|
}, { reserveAutorisee: true }, [{ code: 'TABLE_INCONNUE', tour: 3, detail: 'listes' }]],
|
|||
|
|
// Le nombre de listes d'un tour se lit avant ses présences, dans l'ordre où
|
|||
|
|
// indexerPlan refuse : l'épreuve croisée compare les deux sur ce plan.
|
|||
|
|
['une liste de trop au tour 2, un inconnu en réserve au même tour', (plan) => {
|
|||
|
|
plan.tours[1].push([]);
|
|||
|
|
plan.reserves[1].push(ETRANGER);
|
|||
|
|
}, undefined, [
|
|||
|
|
{ code: 'TABLE_INCONNUE', tour: 2, detail: 'listes' },
|
|||
|
|
{ code: 'INCONNU', participant: ETRANGER, tour: 2 },
|
|||
|
|
]],
|
|||
|
|
|
|||
|
|
['un inconnu en réserve', (plan) => {
|
|||
|
|
plan.reserves[1].push(ETRANGER);
|
|||
|
|
}, undefined, [{ code: 'INCONNU', participant: ETRANGER, tour: 2 }]],
|
|||
|
|
|
|||
|
|
['l’exclu en réserve', (plan) => {
|
|||
|
|
plan.reserves[0].push(participant(13));
|
|||
|
|
}, undefined, [{ code: 'EXCLU_PLACE', participant: participant(13), tour: 1 }]],
|
|||
|
|
['l’exclu à la place de 11, passé en réserve permise', (plan) => {
|
|||
|
|
plan.tours[3][0] = participants(5, 7, 13);
|
|||
|
|
plan.reserves[3].push(participant(11));
|
|||
|
|
}, { reserveAutorisee: true }, [{ code: 'EXCLU_PLACE', participant: participant(13), tour: 4 }]],
|
|||
|
|
|
|||
|
|
// 6, assis à la table de numéro 1 au tour 2, occupe aussi la place de 11 à
|
|||
|
|
// la table de numéro 2 ; 11 attend en réserve.
|
|||
|
|
['à deux tables au tour 2', (plan) => {
|
|||
|
|
plan.tours[1][1] = participants(3, 6, 8);
|
|||
|
|
plan.reserves[1].push(participant(11));
|
|||
|
|
}, { reserveAutorisee: true }, [{ code: 'DOUBLE_PLACE', participant: participant(6), tour: 2 }]],
|
|||
|
|
['deux sièges à la même table', (plan) => {
|
|||
|
|
plan.tours[1][0] = participants(6, 6, 12);
|
|||
|
|
plan.reserves[1].push(participant(9));
|
|||
|
|
}, { reserveAutorisee: true }, [{ code: 'DOUBLE_PLACE', participant: participant(6), tour: 2 }]],
|
|||
|
|
['assis et en réserve', (plan) => {
|
|||
|
|
plan.reserves[1].push(participant(6));
|
|||
|
|
}, undefined, [{ code: 'DOUBLE_PLACE', participant: participant(6), tour: 2 }]],
|
|||
|
|
|
|||
|
|
['absent d’un tour', (plan) => {
|
|||
|
|
retirer(plan.tours[2], participant(7));
|
|||
|
|
}, undefined, [{ code: 'NON_ASSIS', participant: participant(7), tour: 3 }]],
|
|||
|
|
['absent d’un tour, la réserve permise', (plan) => {
|
|||
|
|
retirer(plan.tours[2], participant(7));
|
|||
|
|
}, { reserveAutorisee: true }, [{ code: 'NON_ASSIS', participant: participant(7), tour: 3 }]],
|
|||
|
|
['en réserve, la réserve interdite par défaut', (plan) => {
|
|||
|
|
retirer(plan.tours[2], participant(7));
|
|||
|
|
plan.reserves[2].push(participant(7));
|
|||
|
|
}, undefined, [{ code: 'NON_ASSIS', participant: participant(7), tour: 3 }]],
|
|||
|
|
['en réserve, la réserve interdite nommément', (plan) => {
|
|||
|
|
retirer(plan.tours[2], participant(7));
|
|||
|
|
plan.reserves[2].push(participant(7));
|
|||
|
|
}, { reserveAutorisee: false }, [{ code: 'NON_ASSIS', participant: participant(7), tour: 3 }]],
|
|||
|
|
|
|||
|
|
['quatre à la table de numéro 2, de trois places', (plan) => {
|
|||
|
|
retirer(plan.tours[3], participant(11));
|
|||
|
|
plan.tours[3][1].push(participant(11));
|
|||
|
|
}, undefined, [{ code: 'CAPACITE', table: table(2), tour: 4 }]],
|
|||
|
|
|
|||
|
|
// 6, réservé à la table de numéro 1 au tour 2, échange sa place avec 3,
|
|||
|
|
// assis à la table de numéro 2.
|
|||
|
|
['le réservé échangé avec un convive d’une autre table', (plan) => {
|
|||
|
|
plan.tours[1][0] = participants(3, 9, 12);
|
|||
|
|
plan.tours[1][1] = participants(6, 8, 11);
|
|||
|
|
}, undefined, [{ code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 }]],
|
|||
|
|
['le réservé en réserve permise', (plan) => {
|
|||
|
|
retirer(plan.tours[1], participant(6));
|
|||
|
|
plan.reserves[1].push(participant(6));
|
|||
|
|
}, { reserveAutorisee: true }, [{ code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 }]],
|
|||
|
|
// La même réserve, interdite : 6 n'est pas assis, et sa réservation n'est
|
|||
|
|
// pas honorée. Deux règles enfreintes : NON_ASSIS, puis RESERVATION.
|
|||
|
|
['le réservé en réserve, la réserve interdite', (plan) => {
|
|||
|
|
retirer(plan.tours[1], participant(6));
|
|||
|
|
plan.reserves[1].push(participant(6));
|
|||
|
|
}, undefined, [
|
|||
|
|
{ code: 'NON_ASSIS', participant: participant(6), tour: 2 },
|
|||
|
|
{ code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 },
|
|||
|
|
]],
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
describe('verifierInvariants : le plan parfait de la petite démonstration (§ 15.3)', () => {
|
|||
|
|
test('ne viole rien, la réserve interdite ou permise', () => {
|
|||
|
|
assert.deepEqual(verifierInvariants(INSTANCE, PLAN_PARFAIT), []);
|
|||
|
|
assert.deepEqual(verifierInvariants(INSTANCE, PLAN_PARFAIT, { reserveAutorisee: true }), []);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('ne viole rien, ses tables déclarées dans un autre ordre et ses listes dans le désordre', () => {
|
|||
|
|
// L'ordre de l'instance, 23, 21, 24, 22 : chaque liste suit sa table.
|
|||
|
|
const ordre = [2, 0, 3, 1];
|
|||
|
|
const permute = gelerPlan({
|
|||
|
|
tables: ordre.map((i) => PLAN_PARFAIT.tables[i]),
|
|||
|
|
tours: PLAN_PARFAIT.tours.map((listes) => ordre.map((i) => [...listes[i]].reverse())),
|
|||
|
|
reserves: [[], [], [], []],
|
|||
|
|
});
|
|||
|
|
assert.deepEqual(verifierInvariants(INSTANCE, permute), []);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('verifierInvariants : chaque mutation, sa violation et aucune autre (§ 14.12)', () => {
|
|||
|
|
test('chaque code a sa mutation, et chaque mutation produit exactement ses violations', () => {
|
|||
|
|
const seuls = new Set(MUTATIONS
|
|||
|
|
.filter(([, , , attendu]) => attendu.length === 1)
|
|||
|
|
.map(([, , , [{ code }]]) => code));
|
|||
|
|
assert.deepEqual(CODES.filter((code) => !seuls.has(code)), [], 'code sans mutation qui le produise seul');
|
|||
|
|
const cas = MUTATIONS.map(([libelle, modifier, options, attendu]) => [
|
|||
|
|
libelle,
|
|||
|
|
() => apresMutation(modifier, options),
|
|||
|
|
attendu,
|
|||
|
|
]);
|
|||
|
|
assert.deepEqual(ecarts(cas), []);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('NON_ASSIS : chaque participant retiré de chaque tour, et lui seul, la réserve interdite ou permise', () => {
|
|||
|
|
// La réserve est une liste du plan (§ 8.9) : un participant qui n'y
|
|||
|
|
// figure pas plus qu'à une table n'attend pas, il manque.
|
|||
|
|
const cas = [];
|
|||
|
|
for (const reserveAutorisee of [false, true]) {
|
|||
|
|
for (let tour = 1; tour <= 4; tour += 1) {
|
|||
|
|
for (let numero = 1; numero <= 12; numero += 1) {
|
|||
|
|
const id = participant(numero);
|
|||
|
|
cas.push([
|
|||
|
|
`${numero} retiré du tour ${tour}, réserve ${reserveAutorisee ? 'permise' : 'interdite'}`,
|
|||
|
|
() => apresMutation((plan) => retirer(plan.tours[tour - 1], id), { reserveAutorisee }),
|
|||
|
|
[{ code: 'NON_ASSIS', participant: id, tour }],
|
|||
|
|
]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
assert.equal(cas.length, 96);
|
|||
|
|
assert.deepEqual(ecarts(cas), []);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('DOUBLE_PLACE : chaque participant ajouté à la réserve de chaque tour, et lui seul', () => {
|
|||
|
|
const cas = [];
|
|||
|
|
for (let tour = 1; tour <= 4; tour += 1) {
|
|||
|
|
for (let numero = 1; numero <= 12; numero += 1) {
|
|||
|
|
const id = participant(numero);
|
|||
|
|
cas.push([
|
|||
|
|
`${numero} en réserve au tour ${tour}`,
|
|||
|
|
() => apresMutation((plan) => plan.reserves[tour - 1].push(id)),
|
|||
|
|
[{ code: 'DOUBLE_PLACE', participant: id, tour }],
|
|||
|
|
]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
assert.equal(cas.length, 48);
|
|||
|
|
assert.deepEqual(ecarts(cas), []);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('CAPACITE : chaque table de chaque tour, nommée par son identifiant', () => {
|
|||
|
|
// Le dernier convive de la table suivante rejoint la table examinée. Le
|
|||
|
|
// dernier n'est jamais 6 au tour 2 : la réservation reste honorée.
|
|||
|
|
const cas = [];
|
|||
|
|
for (let tour = 1; tour <= 4; tour += 1) {
|
|||
|
|
for (let i = 0; i < 4; i += 1) {
|
|||
|
|
cas.push([
|
|||
|
|
`tour ${tour}, table de numéro ${i + 1}`,
|
|||
|
|
() => apresMutation((plan) => {
|
|||
|
|
const listes = plan.tours[tour - 1];
|
|||
|
|
listes[i].push(listes[(i + 1) % 4].pop());
|
|||
|
|
}),
|
|||
|
|
[{ code: 'CAPACITE', table: table(i + 1), tour }],
|
|||
|
|
]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
assert.equal(cas.length, 16);
|
|||
|
|
assert.deepEqual(ecarts(cas), []);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('RESERVATION : chaque participant, réservé à chaque tour, à sa table puis à la suivante', () => {
|
|||
|
|
// Une seule réservation de tour désigné par configuration : à la table
|
|||
|
|
// où le plan parfait assoit la personne, elle est honorée ; à la table
|
|||
|
|
// de numéro suivant, elle ne l'est pas.
|
|||
|
|
const cas = [];
|
|||
|
|
for (let tour = 1; tour <= 4; tour += 1) {
|
|||
|
|
for (let numero = 1; numero <= 12; numero += 1) {
|
|||
|
|
const id = participant(numero);
|
|||
|
|
const sienne = PLAN_PARFAIT.tours[tour - 1].findIndex((liste) => liste.includes(id)) + 1;
|
|||
|
|
const ailleurs = (sienne % 4) + 1;
|
|||
|
|
const reserve = (numeroTable) => normaliser(configuration([
|
|||
|
|
{ participant: id, table: table(numeroTable), portee: 'tour', tour },
|
|||
|
|
]));
|
|||
|
|
cas.push([
|
|||
|
|
`${numero} réservé à sa table au tour ${tour}`,
|
|||
|
|
() => verifierInvariants(reserve(sienne), PLAN_PARFAIT),
|
|||
|
|
[],
|
|||
|
|
]);
|
|||
|
|
cas.push([
|
|||
|
|
`${numero} réservé à une autre table au tour ${tour}`,
|
|||
|
|
() => verifierInvariants(reserve(ailleurs), PLAN_PARFAIT),
|
|||
|
|
[{ code: 'RESERVATION', participant: id, table: table(ailleurs), tour }],
|
|||
|
|
]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
assert.equal(cas.length, 96);
|
|||
|
|
assert.deepEqual(ecarts(cas), []);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('verifierInvariants : ce que chaque code couvre', () => {
|
|||
|
|
test('une personne en réserve n’est signalée que si la réserve est interdite, une personne absente l’est toujours (point d’attention 1)', () => {
|
|||
|
|
const enReserve = (plan) => {
|
|||
|
|
retirer(plan.tours[2], participant(7));
|
|||
|
|
plan.reserves[2].push(participant(7));
|
|||
|
|
};
|
|||
|
|
const absente = (plan) => retirer(plan.tours[2], participant(7));
|
|||
|
|
const nonAssis = [{ code: 'NON_ASSIS', participant: participant(7), tour: 3 }];
|
|||
|
|
assert.deepEqual(apresMutation(enReserve), nonAssis);
|
|||
|
|
assert.deepEqual(apresMutation(enReserve, { reserveAutorisee: true }), []);
|
|||
|
|
assert.deepEqual(apresMutation(absente), nonAssis);
|
|||
|
|
assert.deepEqual(apresMutation(absente, { reserveAutorisee: true }), nonAssis);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('une réservation « tous les tours » se juge à chacun des tours', () => {
|
|||
|
|
// 1 est réservé à la table de numéro 1 à chaque tour ; le plan parfait
|
|||
|
|
// l'y assoit au tour 1, puis aux tables de numéro 4, 3 et 2.
|
|||
|
|
const instance = normaliser(configuration([
|
|||
|
|
RESERVATION_HONOREE,
|
|||
|
|
{ participant: participant(1), table: table(1), portee: 'tous' },
|
|||
|
|
]));
|
|||
|
|
assert.deepEqual(verifierInvariants(instance, PLAN_PARFAIT), [2, 3, 4].map((tour) => (
|
|||
|
|
{ code: 'RESERVATION', participant: participant(1), table: table(1), tour })));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('CAPACITE se juge contre la capacité de la table que la liste désigne', () => {
|
|||
|
|
// Deux tables de capacités différentes, que le plan déclare dans l'ordre
|
|||
|
|
// inverse de l'instance.
|
|||
|
|
const instance = normaliser({
|
|||
|
|
participants: [1, 2, 3, 4, 5, 6].map((id) => ({ id, nom: `P${id}`, appartenance: null })),
|
|||
|
|
tables: [{ id: 7, numero: 1, capacite: 2 }, { id: 5, numero: 2, capacite: 4 }],
|
|||
|
|
tours: 1,
|
|||
|
|
reservations: [],
|
|||
|
|
contraintes: SANS_CONTRAINTE,
|
|||
|
|
});
|
|||
|
|
const plan = (listes) => gelerPlan({ tables: [5, 7], tours: [listes], reserves: [[]] });
|
|||
|
|
assert.deepEqual(verifierInvariants(instance, plan([[1, 2, 3, 4], [5, 6]])), []);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
verifierInvariants(instance, plan([[1, 2, 3], [4, 5, 6]])),
|
|||
|
|
[{ code: 'CAPACITE', table: 7, tour: 1 }],
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('une table inconnue : ses occupants sont placés, hors capacité, sans réservation honorée', () => {
|
|||
|
|
// La table de numéro 1 déclarée sous un identifiant étranger : il est
|
|||
|
|
// inconnu, 21 absente. Ses occupants restent placés ; au tour 1, elle en
|
|||
|
|
// reçoit quatre ; au tour 2, 6 y est assis alors que sa réservation le
|
|||
|
|
// fixe à la table 21.
|
|||
|
|
const obtenu = apresMutation((plan) => {
|
|||
|
|
plan.tables[0] = ETRANGER;
|
|||
|
|
retirer(plan.tours[0], participant(4));
|
|||
|
|
plan.tours[0][0].push(participant(4));
|
|||
|
|
});
|
|||
|
|
assert.deepEqual(obtenu, [
|
|||
|
|
{ code: 'TABLE_INCONNUE', table: ETRANGER, detail: 'inconnue' },
|
|||
|
|
{ code: 'TABLE_INCONNUE', table: table(1), detail: 'absente' },
|
|||
|
|
{ code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 },
|
|||
|
|
]);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('les occupants d’une liste au-delà des tables déclarées sont placés', () => {
|
|||
|
|
const obtenu = apresMutation((plan) => {
|
|||
|
|
retirer(plan.tours[2], participant(7));
|
|||
|
|
plan.tours[2].push([participant(7)]);
|
|||
|
|
});
|
|||
|
|
assert.deepEqual(obtenu, [{ code: 'TABLE_INCONNUE', tour: 3, detail: 'listes' }]);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('CAPACITE compte chaque entrée de la liste, étrangère ou répétée', () => {
|
|||
|
|
// Le siège est la position dans la liste : un id étranger ou répété
|
|||
|
|
// occupe un siège comme un autre.
|
|||
|
|
const obtenu = apresMutation((plan) => {
|
|||
|
|
plan.tours[0][0].push(ETRANGER);
|
|||
|
|
plan.tours[1][0].push(participant(12));
|
|||
|
|
});
|
|||
|
|
assert.deepEqual(obtenu, [
|
|||
|
|
{ code: 'INCONNU', participant: ETRANGER, tour: 1 },
|
|||
|
|
{ code: 'CAPACITE', table: table(1), tour: 1 },
|
|||
|
|
{ code: 'DOUBLE_PLACE', participant: participant(12), tour: 2 },
|
|||
|
|
{ code: 'CAPACITE', table: table(1), tour: 2 },
|
|||
|
|
]);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('un participant présent trois fois dans un tour se signale une fois', () => {
|
|||
|
|
// 6 occupe aussi la place de 11 à la table de numéro 2 et figure en
|
|||
|
|
// réserve ; 11 attend en réserve.
|
|||
|
|
const obtenu = apresMutation((plan) => {
|
|||
|
|
plan.tours[1][1] = participants(3, 6, 8);
|
|||
|
|
plan.reserves[1].push(participant(6), participant(11));
|
|||
|
|
}, { reserveAutorisee: true });
|
|||
|
|
assert.deepEqual(obtenu, [{ code: 'DOUBLE_PLACE', participant: participant(6), tour: 2 }]);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('un identifiant étranger à l’instance se signale une fois par tour où il figure', () => {
|
|||
|
|
const obtenu = apresMutation((plan) => {
|
|||
|
|
plan.reserves[0].push(ETRANGER, participant(13), ETRANGER);
|
|||
|
|
plan.tours[0][0].push(participant(13));
|
|||
|
|
retirer(plan.tours[0], participant(3));
|
|||
|
|
plan.reserves[0].push(participant(3));
|
|||
|
|
plan.reserves[2].push(ETRANGER);
|
|||
|
|
}, { reserveAutorisee: true });
|
|||
|
|
assert.deepEqual(obtenu, [
|
|||
|
|
{ code: 'EXCLU_PLACE', participant: participant(13), tour: 1 },
|
|||
|
|
{ code: 'INCONNU', participant: ETRANGER, tour: 1 },
|
|||
|
|
{ code: 'INCONNU', participant: ETRANGER, tour: 3 },
|
|||
|
|
]);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('une table déclarée trois fois se signale une fois', () => {
|
|||
|
|
const obtenu = apresMutation((plan) => {
|
|||
|
|
plan.tables.push(table(2), table(2));
|
|||
|
|
plan.tours.forEach((listes) => listes.push([], []));
|
|||
|
|
});
|
|||
|
|
assert.deepEqual(obtenu, [{ code: 'TABLE_INCONNUE', table: table(2), detail: 'doublon' }]);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('verifierInvariants : la liste rendue', () => {
|
|||
|
|
test('les violations se rangent dans l’ordre de lecture du plan', () => {
|
|||
|
|
// Un tour de moins : seuls les tours 1 à 3 sont lus. Une table inconnue,
|
|||
|
|
// vide. Au tour 2 : une liste de trop, 6 échangé avec 3, 5 retiré, 2
|
|||
|
|
// passé de la table de numéro 3 à la table de numéro 4, et en réserve un
|
|||
|
|
// étranger, l'exclu et 12. Au tour 3, une liste de trop encore. Le nombre
|
|||
|
|
// de listes ouvre les violations de son tour : celle du tour 2 précède ses
|
|||
|
|
// présences, celle du tour 3 suit tout le tour 2.
|
|||
|
|
const obtenu = apresMutation((plan) => {
|
|||
|
|
plan.tours.pop();
|
|||
|
|
plan.reserves.pop();
|
|||
|
|
plan.tables.push(ETRANGER);
|
|||
|
|
plan.tours.forEach((listes) => listes.push([]));
|
|||
|
|
plan.tours[1][0] = participants(3, 9, 12);
|
|||
|
|
plan.tours[1][1] = participants(6, 8, 11);
|
|||
|
|
plan.tours[1][2] = participants(10);
|
|||
|
|
plan.tours[1][3] = participants(1, 2, 4, 7);
|
|||
|
|
plan.reserves[1].push(ETRANGER, participant(13), participant(12));
|
|||
|
|
plan.tours[1].push([]);
|
|||
|
|
plan.tours[2].push([]);
|
|||
|
|
});
|
|||
|
|
assert.deepEqual(obtenu, [
|
|||
|
|
{ code: 'TOURS' },
|
|||
|
|
{ code: 'TABLE_INCONNUE', table: ETRANGER, detail: 'inconnue' },
|
|||
|
|
{ code: 'TABLE_INCONNUE', tour: 2, detail: 'listes' },
|
|||
|
|
{ code: 'INCONNU', participant: ETRANGER, tour: 2 },
|
|||
|
|
{ code: 'EXCLU_PLACE', participant: participant(13), tour: 2 },
|
|||
|
|
{ code: 'DOUBLE_PLACE', participant: participant(12), tour: 2 },
|
|||
|
|
{ code: 'CAPACITE', table: table(4), tour: 2 },
|
|||
|
|
{ code: 'NON_ASSIS', participant: participant(5), tour: 2 },
|
|||
|
|
{ code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 },
|
|||
|
|
{ code: 'TABLE_INCONNUE', tour: 3, detail: 'listes' },
|
|||
|
|
]);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('les tables absentes se rangent dans l’ordre de l’instance', () => {
|
|||
|
|
// Les tables de numéro 2 et 3 retirées du plan, leurs occupants en
|
|||
|
|
// réserve permise. L'instance range 23 avant 22, à l'inverse du plan et
|
|||
|
|
// des numéros.
|
|||
|
|
const obtenu = apresMutation((plan) => {
|
|||
|
|
plan.tables.splice(1, 2);
|
|||
|
|
plan.tours.forEach((listes, r) => plan.reserves[r].push(...listes.splice(1, 2).flat()));
|
|||
|
|
}, { reserveAutorisee: true });
|
|||
|
|
assert.deepEqual(obtenu, [
|
|||
|
|
{ code: 'TABLE_INCONNUE', table: table(3), detail: 'absente' },
|
|||
|
|
{ code: 'TABLE_INCONNUE', table: table(2), detail: 'absente' },
|
|||
|
|
]);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('NON_ASSIS et RESERVATION se rangent participant par participant', () => {
|
|||
|
|
// Au tour 2, 6 échangé avec 3, et 7 retiré : la violation de 6 précède
|
|||
|
|
// celle de 7, quel que soit son code.
|
|||
|
|
const obtenu = apresMutation((plan) => {
|
|||
|
|
plan.tours[1][0] = participants(3, 9, 12);
|
|||
|
|
plan.tours[1][1] = participants(6, 8, 11);
|
|||
|
|
retirer(plan.tours[1], participant(7));
|
|||
|
|
});
|
|||
|
|
assert.deepEqual(obtenu, [
|
|||
|
|
{ code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 },
|
|||
|
|
{ code: 'NON_ASSIS', participant: participant(7), tour: 2 },
|
|||
|
|
]);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('ne modifie ni le plan ni l’instance, sous chaque mutation', () => {
|
|||
|
|
// planMute rend un plan gelé. L'instance est bâtie pour chaque cas et
|
|||
|
|
// copiée avant tout appel : la copie d'une instance déjà passée au
|
|||
|
|
// vérificateur porterait ce qu'il y aurait écrit, et la comparaison ne le
|
|||
|
|
// verrait pas.
|
|||
|
|
const cas = MUTATIONS.map(([libelle, modifier, options]) => [libelle, () => {
|
|||
|
|
const instance = normaliser(configuration());
|
|||
|
|
const copie = structuredClone(instance);
|
|||
|
|
verifierInvariants(instance, planMute(modifier), options);
|
|||
|
|
assert.deepEqual(instance, copie, 'instance modifiée');
|
|||
|
|
return 'intacte';
|
|||
|
|
}, 'intacte']);
|
|||
|
|
assert.deepEqual(ecarts(cas), []);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('verifierInvariants et indexerPlan : une même lecture du plan (§ 8.9)', () => {
|
|||
|
|
// Code sous lequel indexerPlan refuse chaque violation de forme, désignée
|
|||
|
|
// par son code, suivi de son detail quand elle en porte un. CAPACITE et
|
|||
|
|
// RESERVATION n'en ont pas : indexerPlan n'examine ni les capacités ni les
|
|||
|
|
// réservations.
|
|||
|
|
const cle = ({ code, detail }) => (detail === undefined ? code : `${code} ${detail}`);
|
|||
|
|
const REFUS = new Map([
|
|||
|
|
['TOURS', 'PLAN_TOURS'],
|
|||
|
|
['TABLE_INCONNUE inconnue', 'PLAN_TABLE_INCONNUE'],
|
|||
|
|
['TABLE_INCONNUE doublon', 'PLAN_TABLE_DOUBLON'],
|
|||
|
|
['TABLE_INCONNUE absente', 'PLAN_TABLE_ABSENTE'],
|
|||
|
|
['TABLE_INCONNUE listes', 'PLAN_LISTES'],
|
|||
|
|
['INCONNU', 'PLAN_INCONNU'],
|
|||
|
|
['EXCLU_PLACE', 'PLAN_EXCLU_PLACE'],
|
|||
|
|
['DOUBLE_PLACE', 'PLAN_DOUBLE_PLACE'],
|
|||
|
|
['NON_ASSIS', 'PLAN_NON_ASSIS'],
|
|||
|
|
]);
|
|||
|
|
const SANS_REFUS = new Set(['CAPACITE', 'RESERVATION']);
|
|||
|
|
|
|||
|
|
// Ce qu'indexerPlan fait du plan selon verifierInvariants, la réserve
|
|||
|
|
// permise comme la forme indexée la porte : refuser sous le code de la
|
|||
|
|
// première violation de forme, en désignant le même participant, la même
|
|||
|
|
// table et le même tour ; accepter quand il n'y en a aucune.
|
|||
|
|
function refusAttendu(plan) {
|
|||
|
|
const forme = verifierInvariants(INSTANCE, plan, { reserveAutorisee: true })
|
|||
|
|
.filter(({ code }) => !SANS_REFUS.has(code));
|
|||
|
|
if (forme.length === 0) return 'accepté';
|
|||
|
|
const [premiere] = forme;
|
|||
|
|
const { participant: id, table: idTable, tour } = premiere;
|
|||
|
|
return { code: REFUS.get(cle(premiere)), participant: id, table: idTable, tour };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Ce qu'indexerPlan fait du plan, sous la même forme. Une erreur qui n'est
|
|||
|
|
// pas un refus du contenu se propage.
|
|||
|
|
function refusObtenu(plan) {
|
|||
|
|
try {
|
|||
|
|
indexerPlan(INSTANCE, plan);
|
|||
|
|
return 'accepté';
|
|||
|
|
} catch (erreur) {
|
|||
|
|
if (!(erreur instanceof ErreurConfiguration)) throw erreur;
|
|||
|
|
const { participant: id, table: idTable, tour } = erreur.details;
|
|||
|
|
return { code: erreur.code, participant: id, table: idTable, tour };
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Le plan parfait, chaque plan du tableau des mutations, et pour chaque
|
|||
|
|
// participant à chaque tour : retiré, passé en réserve, ajouté à la réserve.
|
|||
|
|
function plansEprouves() {
|
|||
|
|
const plans = [['le plan parfait', PLAN_PARFAIT]];
|
|||
|
|
for (const [libelle, modifier] of MUTATIONS) plans.push([libelle, planMute(modifier)]);
|
|||
|
|
for (let tour = 1; tour <= 4; tour += 1) {
|
|||
|
|
for (let numero = 1; numero <= 12; numero += 1) {
|
|||
|
|
const id = participant(numero);
|
|||
|
|
plans.push([`${numero} retiré du tour ${tour}`, planMute((plan) => {
|
|||
|
|
retirer(plan.tours[tour - 1], id);
|
|||
|
|
})]);
|
|||
|
|
plans.push([`${numero} passé en réserve au tour ${tour}`, planMute((plan) => {
|
|||
|
|
retirer(plan.tours[tour - 1], id);
|
|||
|
|
plan.reserves[tour - 1].push(id);
|
|||
|
|
})]);
|
|||
|
|
plans.push([`${numero} ajouté à la réserve du tour ${tour}`, planMute((plan) => {
|
|||
|
|
plan.reserves[tour - 1].push(id);
|
|||
|
|
})]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return plans;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
test('indexerPlan refuse la première violation de forme que relève verifierInvariants, et accepte le plan qui n’en a aucune', () => {
|
|||
|
|
const plans = plansEprouves();
|
|||
|
|
const acceptes = plans.filter(([, plan]) => refusAttendu(plan) === 'accepté').length;
|
|||
|
|
assert.ok(acceptes > 0 && acceptes < plans.length, `${acceptes} plans acceptés sur ${plans.length}`);
|
|||
|
|
const cas = plans.map(([libelle, plan]) => [libelle, () => refusObtenu(plan), refusAttendu(plan)]);
|
|||
|
|
assert.deepEqual(ecarts(cas), []);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('verifierIndicateurs : la ligne de décomposition (§ 12.3, § 12.10.5)', () => {
|
|||
|
|
// Chiffres du plan parfait, dans l'ordre canonique : N − 1 = 11, puis 8 pour
|
|||
|
|
// chacun. Chaque inégalité y tient, les deux dernières avec égalité.
|
|||
|
|
const N = 12;
|
|||
|
|
const huit = () => Array.from({ length: N }, () => 8);
|
|||
|
|
// Copie de huit() dont la case index prend la valeur donnée.
|
|||
|
|
const sauf = (index, valeur) => huit().map((v, p) => (p === index ? valeur : v));
|
|||
|
|
|
|||
|
|
// Mutations d'une seule case, de rang index : [libellé, rencontres,
|
|||
|
|
// a priori, réalisé, code attendu]. null en a priori : inconnu.
|
|||
|
|
const mutations = (index) => [
|
|||
|
|
['a priori 12 > N − 1', huit(), sauf(index, 12), huit(), 'PLAFOND_A_PRIORI'],
|
|||
|
|
['a priori 7 < réalisé 8', huit(), sauf(index, 7), huit(), 'ORDRE_PLAFONDS'],
|
|||
|
|
['réalisé 9 > a priori 8', huit(), huit(), sauf(index, 9), 'ORDRE_PLAFONDS'],
|
|||
|
|
['rencontres 9 > réalisé 8', sauf(index, 9), huit(), huit(), 'DEPASSEMENT'],
|
|||
|
|
['réalisé 7 < rencontres 8', huit(), huit(), sauf(index, 7), 'DEPASSEMENT'],
|
|||
|
|
['a priori inconnu, réalisé 12 > N − 1', huit(), null, sauf(index, 12), 'PLAFOND_REALISE'],
|
|||
|
|
['a priori inconnu, rencontres 9 > réalisé 8', sauf(index, 9), null, huit(), 'DEPASSEMENT'],
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
test('les chiffres du plan parfait tiennent, le plafond a priori connu ou inconnu', () => {
|
|||
|
|
assert.deepEqual(verifierIndicateurs({ rencontres: huit() }, huit(), huit()), []);
|
|||
|
|
assert.deepEqual(verifierIndicateurs({ rencontres: huit() }, null, huit()), []);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('ne modifie ni les mesures ni les plafonds, que la ligne tienne ou qu’elle tombe', () => {
|
|||
|
|
// Entrées gelées : une écriture, un tri en place ou un champ ajouté y lève
|
|||
|
|
// TypeError. La copie, prise avant l'appel, verrait de plus ce qu'aurait
|
|||
|
|
// changé une entrée qu'on aurait oublié de geler.
|
|||
|
|
const cas = [
|
|||
|
|
['la ligne tient', huit(), huit(), huit()],
|
|||
|
|
['la ligne tient, a priori inconnu', huit(), null, huit()],
|
|||
|
|
...mutations(5).map(([libelle, rencontres, aPriori, realises]) => [libelle, rencontres, aPriori, realises]),
|
|||
|
|
].map(([libelle, rencontres, aPriori, realises]) => [libelle, () => {
|
|||
|
|
const mesures = Object.freeze({ rencontres: Object.freeze(rencontres) });
|
|||
|
|
if (aPriori !== null) Object.freeze(aPriori);
|
|||
|
|
Object.freeze(realises);
|
|||
|
|
const copie = structuredClone({ mesures, aPriori, realises });
|
|||
|
|
verifierIndicateurs(mesures, aPriori, realises);
|
|||
|
|
assert.deepEqual({ mesures, aPriori, realises }, copie);
|
|||
|
|
return 'intactes';
|
|||
|
|
}, 'intactes']);
|
|||
|
|
assert.deepEqual(ecarts(cas), []);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('chaque mutation fait tomber une inégalité de la ligne, et aucune autre', () => {
|
|||
|
|
const cas = mutations(5).map(([libelle, rencontres, aPriori, realises, code]) => [
|
|||
|
|
libelle,
|
|||
|
|
() => verifierIndicateurs({ rencontres }, aPriori, realises),
|
|||
|
|
[{ code, index: 5 }],
|
|||
|
|
]);
|
|||
|
|
assert.deepEqual(ecarts(cas), []);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('chaque personne est éprouvée, sur chaque inégalité', () => {
|
|||
|
|
const cas = [];
|
|||
|
|
for (let index = 0; index < N; index += 1) {
|
|||
|
|
for (const [libelle, rencontres, aPriori, realises, code] of mutations(index)) {
|
|||
|
|
cas.push([
|
|||
|
|
`${libelle}, rang ${index}`,
|
|||
|
|
() => verifierIndicateurs({ rencontres }, aPriori, realises),
|
|||
|
|
[{ code, index }],
|
|||
|
|
]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
assert.equal(cas.length, N * 7);
|
|||
|
|
assert.deepEqual(ecarts(cas), []);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('chaque inégalité est large : l’égalité tient sur toute la ligne', () => {
|
|||
|
|
const onze = Array.from({ length: N }, () => 11);
|
|||
|
|
assert.deepEqual(verifierIndicateurs({ rencontres: onze }, onze, onze), []);
|
|||
|
|
assert.deepEqual(verifierIndicateurs({ rencontres: onze }, null, onze), []);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('les violations se rangent par index croissant, puis de gauche à droite sur la ligne', () => {
|
|||
|
|
// Au rang 2, les trois inégalités tombent : a priori 12 > N − 1, réalisé
|
|||
|
|
// 13 > a priori 12, rencontres 14 > réalisé 13. Au rang 7, la dernière
|
|||
|
|
// seule : rencontres 9 > réalisé 8.
|
|||
|
|
const rencontres = sauf(2, 14);
|
|||
|
|
rencontres[7] = 9;
|
|||
|
|
const realises = sauf(2, 13);
|
|||
|
|
assert.deepEqual(verifierIndicateurs({ rencontres }, sauf(2, 12), realises), [
|
|||
|
|
{ code: 'PLAFOND_A_PRIORI', index: 2 },
|
|||
|
|
{ code: 'ORDRE_PLAFONDS', index: 2 },
|
|||
|
|
{ code: 'DEPASSEMENT', index: 2 },
|
|||
|
|
{ code: 'DEPASSEMENT', index: 7 },
|
|||
|
|
]);
|
|||
|
|
assert.deepEqual(verifierIndicateurs({ rencontres }, null, realises), [
|
|||
|
|
{ code: 'PLAFOND_REALISE', index: 2 },
|
|||
|
|
{ code: 'DEPASSEMENT', index: 2 },
|
|||
|
|
{ code: 'DEPASSEMENT', index: 7 },
|
|||
|
|
]);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('les tableaux typés du moteur se lisent comme des listes', () => {
|
|||
|
|
const type = (valeurs) => Int32Array.from(valeurs);
|
|||
|
|
assert.deepEqual(verifierIndicateurs({ rencontres: type(huit()) }, type(huit()), type(huit())), []);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
verifierIndicateurs({ rencontres: type(huit()) }, type(sauf(3, 7)), type(huit())),
|
|||
|
|
[{ code: 'ORDRE_PLAFONDS', index: 3 }],
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('une valeur qui n’est pas un nombre fait tomber les inégalités qui la lisent', () => {
|
|||
|
|
// Converti, un texte tiendrait l'inégalité face à un 8 : '8' en terme de
|
|||
|
|
// droite, '9' en terme de gauche. Chaque côté a donc son contrôle de type
|
|||
|
|
// éprouvé.
|
|||
|
|
const cas = [
|
|||
|
|
['rencontres NaN', sauf(3, Number.NaN), huit(), huit(), ['DEPASSEMENT']],
|
|||
|
|
['réalisé NaN', huit(), huit(), sauf(3, Number.NaN), ['ORDRE_PLAFONDS', 'DEPASSEMENT']],
|
|||
|
|
['a priori NaN', huit(), sauf(3, Number.NaN), huit(), ['PLAFOND_A_PRIORI', 'ORDRE_PLAFONDS']],
|
|||
|
|
['a priori null', huit(), sauf(3, null), huit(), ['PLAFOND_A_PRIORI', 'ORDRE_PLAFONDS']],
|
|||
|
|
['réalisé indéfini', huit(), huit(), sauf(3, undefined), ['ORDRE_PLAFONDS', 'DEPASSEMENT']],
|
|||
|
|
['rencontres en texte', sauf(3, '8'), huit(), huit(), ['DEPASSEMENT']],
|
|||
|
|
['réalisé en texte', huit(), huit(), sauf(3, '9'), ['ORDRE_PLAFONDS', 'DEPASSEMENT']],
|
|||
|
|
['a priori en texte', huit(), sauf(3, '9'), huit(), ['PLAFOND_A_PRIORI', 'ORDRE_PLAFONDS']],
|
|||
|
|
['a priori inconnu, réalisé null', huit(), null, sauf(3, null), ['PLAFOND_REALISE', 'DEPASSEMENT']],
|
|||
|
|
].map(([libelle, rencontres, aPriori, realises, codes]) => [
|
|||
|
|
libelle,
|
|||
|
|
() => verifierIndicateurs({ rencontres }, aPriori, realises),
|
|||
|
|
codes.map((code) => ({ code, index: 3 })),
|
|||
|
|
]);
|
|||
|
|
assert.deepEqual(ecarts(cas), []);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('un tableau de plafonds d’une autre longueur que les rencontres lève RangeError', () => {
|
|||
|
|
const court = huit().slice(1);
|
|||
|
|
const long = [...huit(), 8];
|
|||
|
|
assert.throws(() => verifierIndicateurs({ rencontres: huit() }, huit(), court), RangeError);
|
|||
|
|
assert.throws(() => verifierIndicateurs({ rencontres: huit() }, long, huit()), RangeError);
|
|||
|
|
assert.throws(() => verifierIndicateurs({ rencontres: huit() }, null, long), RangeError);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('un plafond qui n’est pas une liste lève TypeError, et seul null dit l’a priori inconnu', () => {
|
|||
|
|
// undefined est la valeur d'un champ manquant : lu comme l'a priori
|
|||
|
|
// inconnu, il ôterait deux inégalités à la garde sans rien signaler.
|
|||
|
|
const rencontres = huit();
|
|||
|
|
for (const valeur of [undefined, 8, {}]) {
|
|||
|
|
assert.throws(() => verifierIndicateurs({ rencontres }, valeur, huit()),
|
|||
|
|
{ name: 'TypeError', message: /plafondsAPriori/ }, `a priori ${String(valeur)}`);
|
|||
|
|
}
|
|||
|
|
for (const valeur of [undefined, null, 8, {}]) {
|
|||
|
|
assert.throws(() => verifierIndicateurs({ rencontres }, huit(), valeur),
|
|||
|
|
{ name: 'TypeError', message: /plafondsRealises/ }, `réalisé ${String(valeur)}`);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|