gestion_table_tournante_libre/src/stockage/placements.test.js
Mathieu Benoit 15148e3a26 [FIX] storage: compact seat patches, checked poses, 32-bit identifiers
Without seat attribution a table list stays sorted, so swapping two
people shifted every rank between them and a patch grew to over a
kilobyte; a same-length list of ids is now set whole when that is
lighter, and a swap costs two small operations (§ 8.9). A patch that
sets a malformed proposition or retained placement is refused at its
rank, instead of a TypeError at the next application. Identifiers stay
below 2^31, the engine's Int32Array bound: beyond, the record is a
form fault, and a proposition is set aside.

Checked: storage tests; reviewed, remaining findings minor.

--- FR ---

[FIX] stockage : correctifs compacts, poses contrôlées, id sous 2^31

Sans attribution des sièges, une liste de table reste triée : échanger
deux personnes décalait chaque rang entre elles, et un correctif passait
le kilo-octet ; une liste d'identifiants de même longueur se pose
désormais entière quand c'est plus léger, et un échange coûte deux
petites opérations (§ 8.9). Un correctif qui pose une proposition ou un
retenu mal formé est refusé à son rang, au lieu d'une TypeError à
l'application suivante. Les identifiants restent sous 2^31, borne des
Int32Array du moteur : au-delà, faute de forme, et proposition écartée.

Vérifié : épreuves du stockage ; revue, constats restants mineurs.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 19:22:02 -04:00

1422 lines
74 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des placements du fichier d'état (§ 8.9, § 9, § 4.4, § 5.7) : la
// conversion entre la proposition du moteur et la forme positionnelle, la
// numérotation des générations qui s'accumulent, le contrôle de cohérence
// sur un fichier abîmé, aux capacités égales ou non, ce que ce contrôle
// n'attrape pas, la dérive d'une proposition sur une configuration changée,
// le partage d'une charge lue entre propositions gardées et écartées, sous
// un compteur relevé au-delà de chaque identifiant lu, la forme nommée, et
// des résultats qui ne dépendent que des arguments. Les charges d'épreuve
// reprennent la petite démonstration (§ 15.3) et son plan parfait ; leurs
// noms sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js';
import { indexerPlan, normaliser } from '../moteur/configuration.js';
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { HISTORIQUE_PAR_DEFAUT, rechercher, regenerer } from '../moteur/recherche.js';
import { VERSION } from '../version.genere.js';
import { serialiser } from './canonique.js';
import { analyser, configurationDepuisCharge, creerCharge } from './document.js';
import { ErreurStockage } from './erreurs.js';
import { derive, examiner, fautes, formeNommee, planDepuisFichier, versFichier } from './placements.js';
const ENTETE = Object.freeze({ revision: 1, produitVersion: VERSION.affichee });
// Fige une valeur à toute profondeur et la rend : une fonction qui
// écrirait dans ce qu'elle reçoit lève alors TypeError.
function geler(valeur) {
if (typeof valeur === 'object' && valeur !== null) {
for (const enfant of Object.values(valeur)) geler(enfant);
Object.freeze(valeur);
}
return valeur;
}
// La configuration de la petite démonstration (§ 15.3), neuve à chaque
// appel : 12 personnes, 4 tables de 3 sièges, 4 tours, aucune réservation.
const petite = () => CATALOGUE.find(({ cle }) => cle === 'petite').construire();
// Trois propositions du moteur sur la petite démonstration, figées.
const GENERATION = geler(rechercher(petite(), { graine: 5, arret: 2_000, nombre: 3 }));
// Le plan parfait de la petite démonstration, en proposition du moteur.
const PARFAITE = Object.freeze({ id: 1, graine: 48_271, arret: 20_000, historique: 1_000, plan: PLAN_PARFAIT_PETITE });
// La forme positionnelle attendue de PARFAITE, sièges non attribués, écrite
// à la main : une copie neuve à chaque appel.
function fichierParfait() {
return {
id: 1,
graine: 48_271,
arret: 20_000,
historique: 1_000,
produitVersion: VERSION.affichee,
siegesAttribues: false,
tables: [1, 2, 3, 4],
capacites: [3, 3, 3, 3],
tours: 4,
participants: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
placement: [
{ sieges: [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]], reserve: [] },
{ sieges: [[6, 9, 12], [3, 8, 11], [2, 5, 10], [1, 4, 7]], reserve: [] },
{ sieges: [[4, 8, 10], [2, 7, 12], [1, 6, 11], [3, 5, 9]], reserve: [] },
{ sieges: [[5, 7, 11], [1, 9, 10], [3, 4, 12], [2, 6, 8]], reserve: [] },
],
};
}
// fichierParfait, cohérent lui aussi, ses tables déclarées dans l'ordre 3,
// 1, 2, 4, chaque tour portant ses listes dans cet ordre, et ses
// participants déclarés à rebours : un ordre de sortie qui suivrait celui
// de la déclaration s'y voit.
function fichierPermute() {
const p = fichierParfait();
const ordre = [2, 0, 1, 3];
p.tables = ordre.map((rang) => p.tables[rang]);
p.capacites = ordre.map((rang) => p.capacites[rang]);
for (const tour of p.placement) tour.sieges = ordre.map((rang) => tour.sieges[rang]);
p.participants.reverse();
return p;
}
// fichierParfait, cohérent lui aussi, dont le tour 1 laisse les personnes 6
// et 3 à la réserve, écrite dans cet ordre, hors de l'ordre croissant.
function fichierAReserve() {
const p = fichierParfait();
p.placement[0] = { sieges: [[1, 2], [4, 5], [7, 8, 9], [10, 11, 12]], reserve: [6, 3] };
return p;
}
// fichierParfait, cohérent lui aussi, la table 4 portée à quatre sièges et
// déclarée la première : tables 4, 1, 2, 3, capacités 4, 3, 3, 3. Au tour 1,
// la table 3 cède la personne 9 à la table 4, pleine à sa propre capacité,
// au-delà de celle de ses voisines. Une capacité lue au rang de
// l'identifiant moins un, et non au rang de la déclaration, donnerait 3 à la
// table 4 et 4 à la table 1.
function fichierInegal() {
const p = fichierParfait();
const ordre = [3, 0, 1, 2];
p.tables = ordre.map((rang) => p.tables[rang]);
p.capacites = [4, 3, 3, 3];
for (const tour of p.placement) tour.sieges = ordre.map((rang) => tour.sieges[rang]);
p.placement[0].sieges = [[9, 10, 11, 12], [1, 2, 3], [4, 5, 6], [7, 8]];
return p;
}
// Noms et prénoms inventés des personnes 1 à 12 de la charge d'épreuve, puis
// ceux de la personne 13, que des épreuves ajoutent.
const PERSONNES = Object.freeze([
['Aubépine', 'Agathe'],
['Bruyère', 'Basile'],
['Chardon', 'Céleste'],
['Doucette', 'Désiré'],
['Églantine', 'Edmée'],
['Fougère', 'Firmin'],
['Giroflée', 'Gisèle'],
['Houblon', 'Honoré'],
['Isatis', 'Ida'],
['Jonquille', 'Joachim'],
['Lavande', 'Lazare'],
['Mélisse', 'Mathurin'],
]);
const TREIZIEME = Object.freeze(['Nigelle', 'Noémie']);
// Appartenances inventées, données aux groupes de la démonstration dans
// l'ordre où ils apparaissent, par identifiant croissant.
const CERCLES = Object.freeze(['Cercle Aulne', 'Cercle Bouleau', 'Cercle Charme']);
const personne = (id, nom, prenom, appartenance = null) => ({
id,
nom,
prenom,
appartenance,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
});
const table = (id, numero, sieges = null) => ({ id, numero, sieges, forme: 'ronde', position: { x: 300 * id, y: 0 } });
// La charge de la petite démonstration, sans proposition : ses personnes
// sous des noms inventés et leurs groupes, ses tables aux sièges du défaut,
// 3, ses 4 tours.
function chargePetite() {
const configuration = petite();
const charge = creerCharge({ id: 'evt-pinsons', nom: 'Soirée des Pinsons', siegesParDefaut: 3, tours: 4 });
const cercleDe = new Map();
charge.participants = configuration.participants.map(({ id, appartenance }) => {
if (!cercleDe.has(appartenance)) cercleDe.set(appartenance, CERCLES[cercleDe.size]);
const [nom, prenom] = PERSONNES[id - 1];
return personne(id, nom, prenom, cercleDe.get(appartenance));
});
charge.tables = configuration.tables.map(({ id, numero }) => table(id, numero));
charge.prochainsIds = { participant: 13, table: 5, proposition: 1 };
return charge;
}
// chargePetite avec les propositions de GENERATION, converties sans
// attribution de sièges sur sa propre configuration et numérotées à partir
// de prochainsIds.proposition, qui avance au-delà de la dernière.
function chargeAvecGeneration() {
const charge = chargePetite();
const configuration = configurationDepuisCharge(charge);
const decalage = charge.prochainsIds.proposition - 1;
charge.propositions = GENERATION.map((proposition) =>
versFichier(proposition, configuration, { produitVersion: VERSION.affichee, attribuerSieges: false, decalage }),
);
charge.prochainsIds.proposition = decalage + GENERATION.length + 1;
charge.evenement.etat = 'propose';
return charge;
}
// Le retenu tiré d'une proposition de fichier : son identifiant à la clé
// proposition, une copie de son plan.
const retenuDe = ({ id, siegesAttribues, tables, capacites, tours, participants, placement }) =>
structuredClone({ proposition: id, siegesAttribues, tables, capacites, tours, participants, placement });
// La charge écrite dans son fichier, puis relue : ce que trouve l'ouverture.
const relire = (charge) => analyser(serialiser(charge, ENTETE)).charge;
// Le fichier de la charge, abîmé : abimer reçoit le JSON du texte écrit et
// le modifie, puis le texte qui en résulte est relu par l'analyse, qui ne
// contrôle des propositions et du retenu que leur conteneur.
function abimerFichier(charge, abimer) {
const ecrit = JSON.parse(serialiser(charge, ENTETE));
abimer(ecrit);
return analyser(JSON.stringify(ecrit)).charge;
}
// Exige que appel lève TypeError, et que son message réponde à motif.
function leveTypeError(appel, motif, message) {
assert.throws(appel, (erreur) => erreur instanceof TypeError && motif.test(erreur.message), message);
}
// Code et détails de l'ErreurStockage que lève appel, ou un texte qui dit
// l'écart : une autre erreur, aucun refus.
function refusDe(appel) {
try {
appel();
} catch (erreur) {
if (!(erreur instanceof ErreurStockage)) return `${erreur?.name} au lieu d'une ErreurStockage : ${erreur?.message}`;
return { code: erreur.code, details: erreur.details };
}
return 'aucun refus';
}
// Le refus d'un identifiant qui ne laisserait au compteur des propositions
// aucune valeur au-delà.
const COMPTEUR_SATURE = Object.freeze({ code: 'COMPTEUR_SATURE', details: Object.freeze({ compteur: 'proposition' }) });
// Une ligne attendue de la forme nommée.
const ligne = (tour, idTable, numero, siege, participant, nom, prenom) => ({
tour,
table: idTable,
numero,
siege,
participant,
nom,
prenom,
});
describe('versFichier et planDepuisFichier : la proposition du moteur et sa forme positionnelle (§ 8.9)', () => {
test('le plan parfait de la petite démonstration donne la forme positionnelle attendue, et planDepuisFichier rend le plan', () => {
const fichier = versFichier(PARFAITE, petite(), { produitVersion: VERSION.affichee, attribuerSieges: false, decalage: 0 });
assert.deepStrictEqual(fichier, fichierParfait());
assert.deepStrictEqual(planDepuisFichier(fichier), PLAN_PARFAIT_PETITE);
});
test("chaque proposition de deux générations, sous l'historique par défaut du moteur puis sous un autre : graine, arrêt, historique et produitVersion portés, les trois premiers régénèrent le plan, listes de table croissantes, le plan rendu tel quel", () => {
// GENERATION suit l'historique que le moteur prend par défaut ; la
// seconde génération en demande un autre : sur elle seule, un fichier
// qui porte le défaut au lieu de l'historique reçu se distingue, et sa
// régénération rend un autre plan (§ 8.9).
const seconde = rechercher(petite(), { graine: 5, arret: 500, nombre: 1, historique: 37 });
assert.notEqual(HISTORIQUE_PAR_DEFAUT, 37);
assert.deepStrictEqual(seconde.map(({ historique }) => historique), [37]);
assert.equal(GENERATION.length, 3);
for (const proposition of [...GENERATION, ...seconde]) {
for (const attribuerSieges of [false, true]) {
const fichier = versFichier(proposition, petite(), { produitVersion: VERSION.affichee, attribuerSieges, decalage: 0 });
const { id, graine, arret, historique } = proposition;
assert.deepStrictEqual(
[fichier.id, fichier.graine, fichier.arret, fichier.historique, fichier.produitVersion],
[id, graine, arret, historique, VERSION.affichee],
);
assert.equal(fichier.siegesAttribues, attribuerSieges);
assert.deepStrictEqual(fichier.tables, [1, 2, 3, 4]);
assert.deepStrictEqual(fichier.capacites, [3, 3, 3, 3]);
assert.equal(fichier.tours, 4);
assert.deepStrictEqual(fichier.participants, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
assert.equal(fichier.placement.length, 4);
for (const { sieges, reserve } of fichier.placement) {
for (const liste of sieges) assert.deepStrictEqual(liste, [...liste].sort((a, b) => a - b));
assert.deepStrictEqual(reserve, []);
}
assert.deepStrictEqual(fautes(fichier), []);
assert.deepStrictEqual(planDepuisFichier(fichier), proposition.plan);
assert.deepStrictEqual(regenerer(petite(), fichier), proposition.plan);
}
}
});
test("sièges non attribués : chaque liste de table se trie par identifiant croissant ; attribués : l'ordre des sièges est gardé ; la réserve se trie dans les deux cas", () => {
const configuration = petite();
configuration.participants.push({ id: 13, nom: TREIZIEME[0], prenom: TREIZIEME[1], appartenance: null });
const inverses = PLAN_PARFAIT_PETITE.tours.map((listes) => listes.map((liste) => [...liste].reverse()));
inverses[0][3] = [11, 10];
const proposition = {
...PARFAITE,
plan: { tables: [1, 2, 3, 4], tours: inverses, reserves: [[13, 12], [13], [13], [13]] },
};
const reserves = [[12, 13], [13], [13], [13]];
const nonAttribues = versFichier(proposition, configuration, { produitVersion: VERSION.affichee, attribuerSieges: false, decalage: 0 });
assert.equal(nonAttribues.siegesAttribues, false);
assert.deepStrictEqual(nonAttribues.participants, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]);
const tries = PLAN_PARFAIT_PETITE.tours.map((listes) => listes.map((liste) => [...liste]));
tries[0][3] = [10, 11];
assert.deepStrictEqual(
nonAttribues.placement,
tries.map((sieges, r) => ({ sieges, reserve: reserves[r] })),
);
const attribues = versFichier(proposition, configuration, { produitVersion: VERSION.affichee, attribuerSieges: true, decalage: 0 });
assert.equal(attribues.siegesAttribues, true);
assert.deepStrictEqual(
attribues.placement,
inverses.map((sieges, r) => ({ sieges, reserve: reserves[r] })),
);
assert.deepStrictEqual(planDepuisFichier(attribues), planDepuisFichier(nonAttribues));
});
test("les tables dans l'ordre du plan, chacune avec sa capacité dans la configuration, quel que soit l'ordre où la configuration les range", () => {
const options = { produitVersion: VERSION.affichee, attribuerSieges: false, decalage: 0 };
const configuration = petite();
configuration.tables[3].capacite = 4;
configuration.tables.reverse();
const [proposition] = rechercher(configuration, { graine: 5, arret: 2_000, nombre: 1 });
assert.deepStrictEqual(proposition.plan.tables, [4, 3, 2, 1]);
const engendre = versFichier(proposition, configuration, options);
assert.deepStrictEqual([engendre.tables, engendre.capacites], [[4, 3, 2, 1], [4, 3, 3, 3]]);
assert.deepStrictEqual(planDepuisFichier(engendre), proposition.plan);
const parfait = versFichier(PARFAITE, configuration, options);
assert.deepStrictEqual([parfait.tables, parfait.capacites], [[1, 2, 3, 4], [3, 3, 3, 4]]);
assert.deepStrictEqual(planDepuisFichier(parfait), PLAN_PARFAIT_PETITE);
// Les participants se déclarent par identifiant croissant, même quand
// la configuration les range à rebours.
const renversee = petite();
renversee.participants.reverse();
const [tiree] = rechercher(renversee, { graine: 5, arret: 2_000, nombre: 1 });
assert.deepStrictEqual(versFichier(tiree, renversee, options).participants, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
});
test("planDepuisFichier rend chaque réserve par identifiant croissant, comme le moteur les rend, quel que soit l'ordre écrit", () => {
const p = fichierAReserve();
assert.deepStrictEqual(fautes(p), []);
assert.deepStrictEqual(planDepuisFichier(p).reserves[0], [3, 6]);
});
test("versFichier ne modifie ni la proposition ni la configuration, et ne partage aucune liste avec elles ; planDepuisFichier non plus avec le fichier", () => {
const configuration = geler(petite());
for (const proposition of GENERATION) {
const fichier = versFichier(proposition, configuration, { produitVersion: VERSION.affichee, attribuerSieges: true, decalage: 0 });
assert.notEqual(fichier.tables, proposition.plan.tables);
fichier.placement.forEach(({ sieges, reserve }, r) => {
assert.notEqual(reserve, proposition.plan.reserves[r]);
sieges.forEach((liste, i) => assert.notEqual(liste, proposition.plan.tours[r][i]));
});
const gele = geler(fichier);
const plan = planDepuisFichier(gele);
assert.notEqual(plan.tables, gele.tables);
plan.tours.forEach((listes, r) => {
assert.notEqual(plan.reserves[r], gele.placement[r].reserve);
listes.forEach((liste, i) => assert.notEqual(liste, gele.placement[r].sieges[i]));
});
}
});
test('versFichier refuse un drapeau, une version ou un décalage qui manquent ou sortent de leur domaine, et un plan que la configuration contredit : TypeError', () => {
// Une troisième proposition : le décalage −2 lui donnerait l'identifiant
// 1, que le contrôle de forme admet.
const troisieme = { ...PARFAITE, id: 3 };
for (const options of [
{ produitVersion: VERSION.affichee, decalage: 0 },
{ produitVersion: VERSION.affichee, attribuerSieges: 'non', decalage: 0 },
{ attribuerSieges: false, decalage: 0 },
{ produitVersion: '', attribuerSieges: false, decalage: 0 },
]) {
assert.throws(() => versFichier(troisieme, petite(), options), TypeError, JSON.stringify(options));
}
// Un décalage hors de son domaine se refuse en son nom, avant toute
// forme.
for (const decalage of [undefined, -2, 0.5, '0']) {
leveTypeError(
() => versFichier(troisieme, petite(), { produitVersion: VERSION.affichee, attribuerSieges: false, decalage }),
/décalage/,
`décalage ${JSON.stringify(decalage) ?? 'absent'}`,
);
}
const options = { produitVersion: VERSION.affichee, attribuerSieges: false, decalage: 0 };
const avecTreize = petite();
avecTreize.participants.push({ id: 13, nom: TREIZIEME[0], appartenance: null });
leveTypeError(() => versFichier(PARFAITE, avecTreize, options), /"MANQUANT"/, 'personne 13 non placée');
const tableReduite = petite();
tableReduite.tables[3].capacite = 2;
leveTypeError(() => versFichier(PARFAITE, tableReduite, options), /"LONGUEUR"/, 'table 4 réduite');
const cinqTours = petite();
cinqTours.tours = 5;
leveTypeError(() => versFichier(PARFAITE, cinqTours, options), /"LONGUEUR"/, 'cinq tours');
const sansTable = petite();
sansTable.tables.pop();
leveTypeError(() => versFichier(PARFAITE, sansTable, options), /"FORME"/, 'table 4 absente');
});
test("versFichier refuse sous un code l'identifiant qui ne laisserait au compteur aucune valeur au-delà, 2^31 − 1 et plus : ErreurStockage COMPTEUR_SATURE, qui nomme le compteur des propositions ; 2^31 − 2, le dernier qui lui en laisse une, s'écrit", () => {
// Une troisième proposition, que son décalage porte comme la première à
// l'identifiant 2^31 − 1.
const troisieme = { ...PARFAITE, id: 3 };
const options = { produitVersion: VERSION.affichee, attribuerSieges: false, decalage: 0 };
assert.equal(versFichier(PARFAITE, petite(), { ...options, decalage: 2 ** 31 - 3 }).id, 2 ** 31 - 2);
const refus = [
[PARFAITE, 2 ** 31 - 2],
[troisieme, 2 ** 31 - 4],
[PARFAITE, 2 ** 31 - 1],
].map(([proposition, decalage]) => [
decalage + proposition.id,
refusDe(() => versFichier(proposition, petite(), { ...options, decalage })),
]);
assert.deepStrictEqual(refus, [
[2 ** 31 - 1, COMPTEUR_SATURE],
[2 ** 31 - 1, COMPTEUR_SATURE],
[2 ** 31, COMPTEUR_SATURE],
]);
});
test("le fichier écrit se relit : examiner garde chaque proposition telle qu'écrite, sans faute ni dérive", () => {
const charge = chargeAvecGeneration();
const examen = examiner(relire(charge));
assert.deepStrictEqual(examen.fautives, []);
assert.deepStrictEqual(examen.derives, []);
assert.deepStrictEqual(examen.retenu, { fautes: [], raisons: [] });
assert.deepStrictEqual(examen.charge.propositions, charge.propositions);
examen.charge.propositions.forEach((proposition, rang) => {
assert.deepStrictEqual(planDepuisFichier(proposition), GENERATION[rang].plan);
});
});
test("des générations qui s'accumulent (§ 5.7) : chacune décalée de prochainsIds.proposition − 1, qui avance ensuite ; après un effacement qui épargne le retenu, aucun identifiant ne revient, et examiner garde chaque proposition relue", () => {
const charge = chargePetite();
const configuration = configurationDepuisCharge(charge);
// L'appelant décale une génération de prochainsIds.proposition − 1,
// puis porte le compteur au-delà du dernier identifiant écrit.
const generer = (graine) => {
const decalage = charge.prochainsIds.proposition - 1;
const options = { produitVersion: VERSION.affichee, attribuerSieges: false, decalage };
const propositions = rechercher(configuration, { graine, arret: 2_000, nombre: 3 });
for (const proposition of propositions) charge.propositions.push(versFichier(proposition, configuration, options));
charge.prochainsIds.proposition = decalage + propositions.length + 1;
};
generer(5);
generer(9);
charge.evenement.etat = 'propose';
assert.deepStrictEqual(charge.propositions.map(({ id }) => id), [1, 2, 3, 4, 5, 6]);
const examen = examiner(relire(charge));
assert.deepStrictEqual(examen.fautives, []);
assert.deepStrictEqual(examen.charge.propositions, charge.propositions);
// Effacer la liste en épargnant le retenu (§ 5.7) garde la proposition
// 2 seule ; la génération suivante prend 7, 8 et 9, aucun des
// identifiants effacés.
charge.retenu = retenuDe(charge.propositions[1]);
charge.propositions = [charge.propositions[1]];
charge.evenement.etat = 'retenu';
generer(13);
assert.deepStrictEqual(charge.propositions.map(({ id }) => id), [2, 7, 8, 9]);
const apres = examiner(relire(charge));
assert.deepStrictEqual(apres.fautives, []);
assert.deepStrictEqual(apres.charge.propositions, charge.propositions);
assert.deepStrictEqual(apres.retenu, { fautes: [], raisons: [] });
});
});
describe('fautes : la corruption délibérée (§ 8.9, points 1 à 3)', () => {
// Les quatre façons d'abîmer une proposition de la petite démonstration,
// chacune dans son tour : chaque fonction abîme la proposition reçue et
// rend la faute que le contrôle doit nommer.
const QUATRE_FACONS = [
['une longueur fausse', (p) => {
const { sieges } = p.placement[0];
sieges[1].push(sieges[0].shift());
return { code: 'LONGUEUR', tour: 1, table: 2, declare: 3, ecrit: 4 };
}],
['un identifiant dupliqué', (p) => {
const { sieges, reserve } = p.placement[1];
reserve.push(sieges[0][0]);
return { code: 'DOUBLON', tour: 2, participant: sieges[0][0] };
}],
['un identifiant inconnu', (p) => {
p.placement[2].reserve.push(13);
return { code: 'INCONNU', tour: 3, participant: 13 };
}],
['un identifiant manquant sans entrée de réserve', (p) => {
const perdu = p.placement[3].sieges[2].pop();
return { code: 'MANQUANT', tour: 4, participant: perdu };
}],
];
test('trois propositions, la deuxième abîmée de quatre façons tour à tour : fautes nomme chacune avec ses coordonnées, examiner écarte la seule fautive et garde les deux autres', () => {
const charge = chargeAvecGeneration();
assert.deepStrictEqual(charge.propositions[1].tables, [1, 2, 3, 4]);
for (const [libelle, abimer] of QUATRE_FACONS) {
let attendue;
const lue = abimerFichier(charge, (ecrit) => {
attendue = abimer(ecrit.charge.propositions[1]);
});
const abimee = lue.propositions[1];
assert.equal(abimee.id, 2, libelle);
assert.notDeepStrictEqual(abimee, charge.propositions[1], libelle);
assert.deepStrictEqual(fautes(abimee), [attendue], libelle);
const examen = examiner(lue);
assert.deepStrictEqual(examen.fautives, [{ rang: 1, id: 2, fautes: [attendue] }], libelle);
assert.deepStrictEqual(examen.charge.propositions, [charge.propositions[0], charge.propositions[2]], libelle);
assert.deepStrictEqual(examen.derives, [], libelle);
}
});
test("les quatre défauts dans la même proposition sont nommés dans l'ordre des tours", () => {
const abimee = structuredClone(chargeAvecGeneration().propositions[1]);
const attendues = QUATRE_FACONS.map(([, abimer]) => abimer(abimee));
assert.deepStrictEqual(fautes(abimee), [attendues[0], attendues[1], attendues[2], attendues[3]]);
assert.deepStrictEqual(attendues.map(({ tour }) => tour), [1, 2, 3, 4]);
});
test("dans un tour : le nombre de listes, chaque table dans l'ordre déclaré, puis doublons, inconnus et manquants par identifiant croissant ; le nombre de tours avant tout", () => {
const p = fichierParfait();
p.tours = 5;
p.placement[1] = { sieges: [[6, 9, 12, 3], [8], [2, 10, 14, 9], [1, 4, 7], [20]], reserve: [15, 1, 13, 15] };
assert.deepStrictEqual(fautes(p), [
{ code: 'LONGUEUR', tour: null, table: null, declare: 5, ecrit: 4 },
{ code: 'LONGUEUR', tour: 2, table: null, declare: 4, ecrit: 5 },
{ code: 'LONGUEUR', tour: 2, table: 1, declare: 3, ecrit: 4 },
{ code: 'LONGUEUR', tour: 2, table: 3, declare: 3, ecrit: 4 },
{ code: 'DOUBLON', tour: 2, participant: 1 },
{ code: 'DOUBLON', tour: 2, participant: 9 },
{ code: 'INCONNU', tour: 2, participant: 13 },
{ code: 'INCONNU', tour: 2, participant: 14 },
{ code: 'INCONNU', tour: 2, participant: 15 },
{ code: 'INCONNU', tour: 2, participant: 20 },
{ code: 'MANQUANT', tour: 2, participant: 5 },
{ code: 'MANQUANT', tour: 2, participant: 11 },
]);
// Plus de tours écrits que déclarés : la même faute, dans l'autre sens.
const enTrop = { ...fichierParfait(), tours: 3 };
assert.deepStrictEqual(fautes(enTrop), [{ code: 'LONGUEUR', tour: null, table: null, declare: 3, ecrit: 4 }]);
});
test("un identifiant se nomme une fois par tour : inconnu écrit deux fois, déclaré écrit trois fois ; une liste de moins que les tables nomme aussi ses absents", () => {
const p = fichierParfait();
p.placement[0].reserve = [13, 2, 13, 2];
p.placement[2].sieges.pop();
assert.deepStrictEqual(fautes(p), [
{ code: 'DOUBLON', tour: 1, participant: 2 },
{ code: 'INCONNU', tour: 1, participant: 13 },
{ code: 'LONGUEUR', tour: 3, table: null, declare: 4, ecrit: 3 },
{ code: 'MANQUANT', tour: 3, participant: 3 },
{ code: 'MANQUANT', tour: 3, participant: 5 },
{ code: 'MANQUANT', tour: 3, participant: 9 },
]);
});
test("les tables suivent l'ordre déclaré, les identifiants l'ordre croissant, quel que soit l'ordre de la déclaration des participants", () => {
const permute = fichierPermute();
assert.deepStrictEqual(permute.tables, [3, 1, 2, 4]);
assert.deepStrictEqual(fautes(permute), []);
const longues = fichierPermute();
longues.placement[0].sieges = [[7, 8, 9, 4], [1, 2, 3, 5], [6], [10, 11, 12]];
assert.deepStrictEqual(fautes(longues), [
{ code: 'LONGUEUR', tour: 1, table: 3, declare: 3, ecrit: 4 },
{ code: 'LONGUEUR', tour: 1, table: 1, declare: 3, ecrit: 4 },
]);
const melees = fichierPermute();
melees.placement[0].sieges[0] = [7, 8];
melees.placement[0].sieges[1] = [1, 3];
melees.placement[0].reserve = [5, 3];
assert.deepStrictEqual(fautes(melees), [
{ code: 'DOUBLON', tour: 1, participant: 3 },
{ code: 'DOUBLON', tour: 1, participant: 5 },
{ code: 'MANQUANT', tour: 1, participant: 2 },
{ code: 'MANQUANT', tour: 1, participant: 9 },
]);
});
test("capacités inégales, tables déclarées hors de l'ordre des identifiants : chaque liste se mesure à la capacité de son rang dans la déclaration, et la proposition se relit sans être écartée", () => {
const inegal = fichierInegal();
assert.deepStrictEqual(fautes(inegal), []);
// La table 4 au-delà de ses quatre sièges, la table 1 au-delà de ses
// trois mais en deçà de quatre ; la table 3 vide n'est pas fautive.
const debordees = fichierInegal();
debordees.placement[0].sieges = [[8, 9, 10, 11, 12], [1, 2, 3, 7], [4, 5, 6], []];
assert.deepStrictEqual(fautes(debordees), [
{ code: 'LONGUEUR', tour: 1, table: 4, declare: 4, ecrit: 5 },
{ code: 'LONGUEUR', tour: 1, table: 1, declare: 3, ecrit: 4 },
]);
const charge = chargePetite();
charge.tables[3].sieges = 4;
charge.propositions = [inegal];
charge.prochainsIds.proposition = 2;
charge.evenement.etat = 'propose';
const examen = examiner(relire(charge));
assert.deepStrictEqual(examen.fautives, []);
assert.deepStrictEqual(examen.derives, []);
assert.deepStrictEqual(examen.charge.propositions, [inegal]);
});
test("une table moins remplie que sa capacité n'est pas fautive : la longueur déclarée borne la liste, une salle peut avoir des places vides", () => {
const configuration = petite();
configuration.participants[4].exclu = true;
const [proposition] = rechercher(configuration, { graine: 9, arret: 1_000, nombre: 1 });
const fichier = versFichier(proposition, configuration, { produitVersion: VERSION.affichee, attribuerSieges: false, decalage: 0 });
assert.deepStrictEqual(fichier.participants, [1, 2, 3, 4, 6, 7, 8, 9, 10, 11, 12]);
for (const { sieges } of fichier.placement) {
assert.deepStrictEqual(sieges.map((liste) => liste.length).sort((a, b) => a - b), [2, 3, 3, 3]);
}
assert.deepStrictEqual(fautes(fichier), []);
const versLaReserve = fichierParfait();
versLaReserve.placement[0] = { sieges: [[1, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]], reserve: [2] };
assert.deepStrictEqual(fautes(versLaReserve), []);
});
test("la forme se contrôle d'abord : la première valeur hors de sa règle est nommée FORME, par son chemin dans la proposition", () => {
const cas = [
['un nombre', () => 42, ''],
['null', () => null, ''],
['une liste', () => [], ''],
['identifiant 0', (p) => { p.id = 0; return p; }, 'id'],
['identifiant en chaîne', (p) => { p.id = '1'; return p; }, 'id'],
['graine négative', (p) => { p.graine = -1; return p; }, 'graine'],
['graine de 2^32', (p) => { p.graine = 2 ** 32; return p; }, 'graine'],
['arrêt nul', (p) => { p.arret = 0; return p; }, 'arret'],
['historique fractionnaire', (p) => { p.historique = 1.5; return p; }, 'historique'],
['version vide', (p) => { p.produitVersion = ''; return p; }, 'produitVersion'],
['drapeau en chaîne', (p) => { p.siegesAttribues = 'non'; return p; }, 'siegesAttribues'],
['tables en objet', (p) => { p.tables = {}; return p; }, 'tables'],
['identifiant de table 0', (p) => { p.tables[3] = 0; return p; }, 'tables[3]'],
["capacité d'un siège", (p) => { p.capacites[2] = 1; return p; }, 'capacites[2]'],
['zéro tour', (p) => { p.tours = 0; return p; }, 'tours'],
['participant en chaîne', (p) => { p.participants[1] = '2'; return p; }, 'participants[1]'],
['placement en objet', (p) => { p.placement = {}; return p; }, 'placement'],
['tour en liste', (p) => { p.placement[0] = []; return p; }, 'placement[0]'],
['sièges en chaîne', (p) => { p.placement[1].sieges = 'x'; return p; }, 'placement[1].sieges'],
['liste de table en nombre', (p) => { p.placement[2].sieges[1] = 7; return p; }, 'placement[2].sieges[1]'],
['occupant fractionnaire', (p) => { p.placement[3].sieges[0][2] = 2.5; return p; }, 'placement[3].sieges[0][2]'],
['occupant au-delà des entiers exacts', (p) => { p.placement[3].sieges[1][0] = 2 ** 53; return p; }, 'placement[3].sieges[1][0]'],
// Un identifiant reste sous 2^31.
['identifiant 2^31', (p) => { p.id = 2 ** 31; return p; }, 'id'],
['identifiant de table 2^31', (p) => { p.tables[3] = 2 ** 31; return p; }, 'tables[3]'],
['participant déclaré 2^31', (p) => { p.participants[11] = 2 ** 31; return p; }, 'participants[11]'],
['occupant 2^31', (p) => { p.placement[3].sieges[1][0] = 2 ** 31; return p; }, 'placement[3].sieges[1][0]'],
['en réserve, 2^31', (p) => { p.placement[2].reserve = [2 ** 31]; return p; }, 'placement[2].reserve[0]'],
['réserve absente', (p) => { delete p.placement[0].reserve; return p; }, 'placement[0].reserve'],
['graine absente', (p) => { delete p.graine; return p; }, 'graine'],
['deux clés inconnues : la plus petite', (p) => { p.zeta = 1; p.alpha = 2; return p; }, 'alpha'],
["une clé inconnue qui ne s'écrit pas comme un identifiant", (p) => { p['clé inconnue'] = 1; return p; }, '["clé inconnue"]'],
['une clé inconnue dans un tour', (p) => { p.placement[1].note = 'x'; return p; }, 'placement[1].note'],
['un champ fautif avant une clé inconnue', (p) => { p.aaa = 1; p.tours = 0; return p; }, 'tours'],
['une table déclarée deux fois', (p) => { p.tables[2] = 2; return p; }, 'tables[2]'],
['des capacités moins nombreuses que les tables', (p) => { p.capacites.pop(); return p; }, 'capacites'],
['des capacités plus nombreuses que les tables', (p) => { p.capacites.push(3); return p; }, 'capacites'],
['un participant déclaré deux fois', (p) => { p.participants[3] = 3; return p; }, 'participants[3]'],
['une forme fautive dans un placement incohérent : la forme seule', (p) => {
p.placement[0].sieges[0].pop();
p.historique = 0;
return p;
}, 'historique'],
];
const ecarts = cas.flatMap(([libelle, abimer, chemin]) => {
const obtenues = fautes(abimer(fichierParfait()));
const attendues = [{ code: 'FORME', chemin }];
return JSON.stringify(obtenues) === JSON.stringify(attendues)
? []
: [`${libelle} : ${JSON.stringify(obtenues)} au lieu de ${JSON.stringify(attendues)}`];
});
assert.deepStrictEqual(ecarts, []);
});
test("le retenu se contrôle sous sa propre forme : la clé proposition au lieu de l'identifiant, ni graine, ni arrêt, ni historique, ni version", () => {
const retenu = retenuDe(fichierParfait());
assert.deepStrictEqual(fautes(retenu), []);
assert.deepStrictEqual(fautes({ ...retenuDe(fichierParfait()), graine: 1 }), [{ code: 'FORME', chemin: 'graine' }]);
const sansDrapeau = retenuDe(fichierParfait());
delete sansDrapeau.siegesAttribues;
assert.deepStrictEqual(fautes(sansDrapeau), [{ code: 'FORME', chemin: 'siegesAttribues' }]);
assert.deepStrictEqual(fautes({ ...retenuDe(fichierParfait()), proposition: 0 }), [{ code: 'FORME', chemin: 'proposition' }]);
retenu.placement[1].sieges[3].pop();
assert.deepStrictEqual(fautes(retenu), [{ code: 'MANQUANT', tour: 2, participant: 7 }]);
});
});
describe("ce que le contrôle n'attrape pas, et ne prétend pas attraper (§ 8.9, point 2)", () => {
test("une permutation dans une table, et l'échange du contenu de deux tables de même capacité, gardent le multiensemble et les longueurs : fautes rend [], examiner garde la proposition", () => {
for (const siegesAttribues of [true, false]) {
const reference = { ...fichierParfait(), siegesAttribues };
const permutee = structuredClone(reference);
permutee.placement[0].sieges[0] = [3, 1, 2];
const echangee = structuredClone(reference);
const { sieges } = echangee.placement[1];
[sieges[0], sieges[1]] = [sieges[1], sieges[0]];
for (const abimee of [permutee, echangee]) {
assert.notDeepStrictEqual(abimee, reference);
assert.deepStrictEqual(fautes(abimee), []);
const charge = chargePetite();
charge.propositions = [abimee];
charge.prochainsIds.proposition = 2;
const examen = examiner(charge);
assert.deepStrictEqual(examen.fautives, []);
assert.deepStrictEqual(examen.derives, []);
assert.deepStrictEqual(examen.charge.propositions, [abimee]);
}
}
});
});
describe('derive : la péremption délibérée (§ 8.9, point 3 ; § 9 ; § 4.4)', () => {
test('exclure 5, supprimer la table 2, réduire la table 3, passer à 3 tours, ajouter 13 : chaque raison nommée, triées par code puis identifiant ; examiner conserve chaque proposition et le retenu', () => {
const charge = chargeAvecGeneration();
charge.retenu = retenuDe(charge.propositions[1]);
charge.evenement.etat = 'retenu';
charge.participants[4].exclu = true;
charge.tables = charge.tables.filter(({ id }) => id !== 2);
charge.tables.find(({ id }) => id === 3).sieges = 2;
charge.evenement.tours = 3;
charge.participants.push(personne(13, ...TREIZIEME));
charge.prochainsIds.participant = 14;
const raisons = [
{ code: 'CAPACITE_CHANGEE', table: 3, avant: 3, maintenant: 2 },
{ code: 'PARTICIPANT_EXCLU', participant: 5 },
{ code: 'PARTICIPANT_NON_PLACE', participant: 13 },
{ code: 'TABLE_SUPPRIMEE', table: 2 },
{ code: 'TOURS_CHANGES', avant: 4, maintenant: 3 },
];
const lue = relire(charge);
for (const proposition of lue.propositions) assert.deepStrictEqual(derive(proposition, lue), raisons);
const examen = examiner(lue);
assert.deepStrictEqual(examen.fautives, []);
assert.deepStrictEqual(examen.derives, [1, 2, 3].map((id) => ({ id, raisons })));
assert.deepStrictEqual(examen.charge.propositions, charge.propositions);
assert.deepStrictEqual(examen.retenu, { fautes: [], raisons });
assert.equal(examen.charge.retenu, lue.retenu);
});
test("chaque changement seul donne sa seule raison ; ce qui ne touche ni les personnes placées, ni le mobilier, ni les tours n'est pas une dérive", () => {
const cas = [
['exclure la personne 5', (c) => { c.participants[4].exclu = true; },
[{ code: 'PARTICIPANT_EXCLU', participant: 5 }]],
['supprimer la personne 7', (c) => { c.participants.splice(6, 1); },
[{ code: 'PARTICIPANT_SUPPRIME', participant: 7 }]],
['ajouter la personne 13', (c) => { c.participants.push(personne(13, ...TREIZIEME)); },
[{ code: 'PARTICIPANT_NON_PLACE', participant: 13 }]],
['ajouter la personne 13, exclue', (c) => { c.participants.push({ ...personne(13, ...TREIZIEME), exclu: true }); },
[]],
['supprimer la table 2', (c) => { c.tables.splice(1, 1); },
[{ code: 'TABLE_SUPPRIMEE', table: 2 }]],
['ajouter la table 5', (c) => { c.tables.push(table(5, 5)); },
[{ code: 'TABLE_AJOUTEE', table: 5 }]],
['réduire la table 3 à deux sièges', (c) => { c.tables[2].sieges = 2; },
[{ code: 'CAPACITE_CHANGEE', table: 3, avant: 3, maintenant: 2 }]],
['agrandir la table 1 à quatre sièges', (c) => { c.tables[0].sieges = 4; },
[{ code: 'CAPACITE_CHANGEE', table: 1, avant: 3, maintenant: 4 }]],
['passer le défaut à quatre sièges', (c) => { c.evenement.siegesParDefaut = 4; },
[1, 2, 3, 4].map((id) => ({ code: 'CAPACITE_CHANGEE', table: id, avant: 3, maintenant: 4 }))],
['surcharger la table 4 à la valeur du défaut', (c) => { c.tables[3].sieges = 3; },
[]],
['passer à cinq tours', (c) => { c.evenement.tours = 5; },
[{ code: 'TOURS_CHANGES', avant: 4, maintenant: 5 }]],
['passer à trois tours', (c) => { c.evenement.tours = 3; },
[{ code: 'TOURS_CHANGES', avant: 4, maintenant: 3 }]],
["renommer, changer d'appartenance, renuméroter, déplacer et changer de forme, attribuer les sièges", (c) => {
c.participants[0].nom = 'Aubier';
c.participants[1].appartenance = null;
c.tables[1].numero = 7;
c.tables[2].position = { x: -40, y: 125.5 };
c.tables[3].forme = 'carree';
c.reglages.attribuerSieges = true;
}, []],
];
for (const [libelle, changer, raisons] of cas) {
const charge = chargeAvecGeneration();
changer(charge);
for (const proposition of charge.propositions) {
assert.deepStrictEqual(derive(proposition, charge), raisons, libelle);
}
const examen = examiner(charge);
assert.deepStrictEqual(examen.fautives, [], libelle);
assert.equal(examen.charge.propositions.length, 3, libelle);
const attendues = raisons.length === 0 ? [] : [1, 2, 3].map((id) => ({ id, raisons }));
assert.deepStrictEqual(examen.derives, attendues, libelle);
}
});
test("les raisons d'un même code se rangent par identifiant, quel que soit l'ordre de la déclaration ou de la charge", () => {
const p = fichierPermute();
const charge = chargePetite();
charge.evenement.siegesParDefaut = 4;
charge.tables = charge.tables.filter(({ id }) => id !== 3 && id !== 1);
charge.tables.push(table(6, 6), table(5, 5));
charge.participants[8].exclu = true;
charge.participants[1].exclu = true;
charge.participants = charge.participants.filter(({ id }) => id !== 11 && id !== 4);
charge.participants.push(personne(14, 'Ortie', 'Ovide'), personne(13, ...TREIZIEME));
assert.deepStrictEqual(derive(p, charge), [
{ code: 'CAPACITE_CHANGEE', table: 2, avant: 3, maintenant: 4 },
{ code: 'CAPACITE_CHANGEE', table: 4, avant: 3, maintenant: 4 },
{ code: 'PARTICIPANT_EXCLU', participant: 2 },
{ code: 'PARTICIPANT_EXCLU', participant: 9 },
{ code: 'PARTICIPANT_NON_PLACE', participant: 13 },
{ code: 'PARTICIPANT_NON_PLACE', participant: 14 },
{ code: 'PARTICIPANT_SUPPRIME', participant: 4 },
{ code: 'PARTICIPANT_SUPPRIME', participant: 11 },
{ code: 'TABLE_AJOUTEE', table: 5 },
{ code: 'TABLE_AJOUTEE', table: 6 },
{ code: 'TABLE_SUPPRIMEE', table: 1 },
{ code: 'TABLE_SUPPRIMEE', table: 3 },
]);
});
test("capacités inégales, tables déclarées hors de l'ordre des identifiants : chaque capacité déclarée se compare à celle de sa table, et seule celle qui change est nommée", () => {
const p = fichierInegal();
const charge = chargePetite();
charge.tables[3].sieges = 4;
assert.deepStrictEqual(derive(p, charge), []);
charge.tables[3].sieges = 5;
assert.deepStrictEqual(derive(p, charge), [{ code: 'CAPACITE_CHANGEE', table: 4, avant: 4, maintenant: 5 }]);
charge.tables[3].sieges = 4;
charge.evenement.siegesParDefaut = 4;
assert.deepStrictEqual(
derive(p, charge),
[1, 2, 3].map((id) => ({ code: 'CAPACITE_CHANGEE', table: id, avant: 3, maintenant: 4 })),
);
});
test("une personne exclue à la génération puis réintégrée n'est pas placée : PARTICIPANT_NON_PLACE", () => {
const configuration = petite();
configuration.participants[4].exclu = true;
const [proposition] = rechercher(configuration, { graine: 9, arret: 1_000, nombre: 1 });
const fichier = versFichier(proposition, configuration, { produitVersion: VERSION.affichee, attribuerSieges: false, decalage: 0 });
const charge = chargePetite();
charge.participants[4].exclu = true;
assert.deepStrictEqual(derive(fichier, charge), []);
charge.participants[4].exclu = false;
assert.deepStrictEqual(derive(fichier, charge), [{ code: 'PARTICIPANT_NON_PLACE', participant: 5 }]);
});
test('sans dérive, le plan passe indexerPlan sur la configuration courante ; une table ajoutée est une dérive, que le moteur refuserait', () => {
const charge = chargeAvecGeneration();
const instance = normaliser(configurationDepuisCharge(charge));
for (const proposition of charge.propositions) {
assert.deepStrictEqual(derive(proposition, charge), []);
indexerPlan(instance, planDepuisFichier(proposition));
}
charge.tables.push(table(5, 5));
const agrandie = normaliser(configurationDepuisCharge(charge));
for (const proposition of charge.propositions) {
assert.deepStrictEqual(derive(proposition, charge), [{ code: 'TABLE_AJOUTEE', table: 5 }]);
assert.throws(
() => indexerPlan(agrandie, planDepuisFichier(proposition)),
(erreur) => erreur instanceof ErreurConfiguration && erreur.code === 'PLAN_TABLE_ABSENTE' && erreur.details.table === 5,
);
}
});
});
describe("examiner : le partage d'une charge lue (§ 8.9, point 3)", () => {
test("un retenu fautif est signalé et reste dans la charge, sans raison de dérive : la reprise par le .precedent n'est pas de son ressort", () => {
const charge = chargeAvecGeneration();
const retenu = retenuDe(charge.propositions[0]);
const perdu = retenu.placement[1].sieges[3].pop();
charge.retenu = retenu;
charge.evenement.etat = 'retenu';
charge.participants[4].exclu = true;
const lue = relire(charge);
const examen = examiner(lue);
assert.deepStrictEqual(examen.retenu, { fautes: [{ code: 'MANQUANT', tour: 2, participant: perdu }], raisons: [] });
assert.equal(examen.charge.retenu, lue.retenu);
assert.deepStrictEqual(examen.fautives, []);
assert.deepStrictEqual(examen.charge.propositions, charge.propositions);
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 5 }];
assert.deepStrictEqual(examen.derives, [1, 2, 3].map((id) => ({ id, raisons })));
});
test("un retenu de forme quelconque, que l'analyse admet, est fautif sous la forme du retenu", () => {
const sansCle = abimerFichier(chargeAvecGeneration(), (ecrit) => {
ecrit.charge.retenu = { inconnue: [] };
ecrit.entete.comptes.retenu = 1;
});
assert.deepStrictEqual(examiner(sansCle).retenu, { fautes: [{ code: 'FORME', chemin: 'proposition' }], raisons: [] });
const charge = chargeAvecGeneration();
const sansProposition = retenuDe(charge.propositions[0]);
delete sansProposition.proposition;
charge.retenu = sansProposition;
assert.deepStrictEqual(examiner(charge).retenu, { fautes: [{ code: 'FORME', chemin: 'proposition' }], raisons: [] });
assert.equal(examiner(charge).charge.retenu, sansProposition);
});
test('un retenu cohérent et périmé porte ses raisons, sans faute ; sans retenu, ni faute ni raison', () => {
const charge = chargeAvecGeneration();
assert.deepStrictEqual(examiner(charge).retenu, { fautes: [], raisons: [] });
charge.retenu = retenuDe(charge.propositions[2]);
assert.deepStrictEqual(examiner(charge).retenu, { fautes: [], raisons: [] });
charge.participants[4].exclu = true;
assert.deepStrictEqual(examiner(charge).retenu, {
fautes: [],
raisons: [{ code: 'PARTICIPANT_EXCLU', participant: 5 }],
});
});
test("deux propositions cohérentes de même identifiant : la seconde dans l'ordre de la liste est fautive, IDENTIFIANT_REPETE, et le fichier s'ouvre sans elle ; une fautive n'écarte pas la cohérente qui porte son identifiant", () => {
const charge = chargeAvecGeneration();
const [p1, p2, p3] = charge.propositions;
const homonyme = { ...structuredClone(p3), id: 2 };
charge.propositions = [p1, p2, homonyme, p3];
const examen = examiner(charge);
assert.deepStrictEqual(examen.fautives, [{ rang: 2, id: 2, fautes: [{ code: 'IDENTIFIANT_REPETE' }] }]);
assert.deepStrictEqual(examen.charge.propositions, [p1, p2, p3]);
assert.equal(examen.charge.propositions[1], p2);
// Au travers du fichier, que l'analyse admet avec ses deux propositions 2.
const lue = examiner(relire(charge));
assert.deepStrictEqual(lue.fautives.map(({ id, fautes }) => [id, fautes]), [[2, [{ code: 'IDENTIFIANT_REPETE' }]]]);
assert.deepStrictEqual(lue.charge.propositions.map(({ id }) => id), [1, 2, 3]);
const abimee = { ...structuredClone(p1), tours: 5 };
charge.propositions = [abimee, p1, p2, p3];
const seconde = examiner(charge);
assert.deepStrictEqual(seconde.fautives, [
{ rang: 0, id: 1, fautes: [{ code: 'LONGUEUR', tour: null, table: null, declare: 5, ecrit: 4 }] },
]);
assert.deepStrictEqual(seconde.charge.propositions, [p1, p2, p3]);
// Après la cohérente, une fautive de même identifiant se nomme par sa
// propre faute, et non comme une répétition.
charge.propositions = [p1, abimee];
assert.deepStrictEqual(examiner(charge).fautives, [
{ rang: 1, id: 1, fautes: [{ code: 'LONGUEUR', tour: null, table: null, declare: 5, ecrit: 4 }] },
]);
});
test("une proposition cohérente dont l'identifiant atteint prochainsIds.proposition est fautive, IDENTIFIANT_HORS_COMPTEUR, et le fichier s'ouvre sans elle ; un retenu dont la proposition d'origine l'atteint porte cette faute, reste, et garde ses raisons de dérive", () => {
const HORS = { code: 'IDENTIFIANT_HORS_COMPTEUR' };
// Les propositions 1, 2 et 3 sous le compteur 4 ; le retenu vient de la
// première. Un octet change dans le fichier écrit : chaque fois,
// l'analyse le lit, et examiner n'écarte que la proposition touchée.
const charge = chargeAvecGeneration();
const [p1, p2, p3] = charge.propositions;
charge.retenu = retenuDe(p1);
charge.evenement.etat = 'retenu';
const texte = serialiser(charge, ENTETE);
const unOctet = ([avant, apres]) => {
assert.equal(texte.split(avant).length, 2, `« ${avant} » doit figurer une fois`);
return examiner(analyser(texte.replace(avant, apres)).charge);
};
// L'identifiant de la troisième proposition, puis le compteur, réécrits.
const troisieme = (id) => ['{"id":3,"graine"', `{"id":${id},"graine"`];
const compteur = (prochain) => ['"proposition":4}', `"proposition":${prochain}}`];
const cas = [
['la troisième au compteur', troisieme(4), [{ rang: 2, id: 4, fautes: [HORS] }], [p1, p2]],
['la troisième au-delà', troisieme(9), [{ rang: 2, id: 9, fautes: [HORS] }], [p1, p2]],
['la troisième répète la deuxième', troisieme(2),
[{ rang: 2, id: 2, fautes: [{ code: 'IDENTIFIANT_REPETE' }] }], [p1, p2]],
['la troisième à 0', troisieme(0), [{ rang: 2, id: null, fautes: [{ code: 'FORME', chemin: 'id' }] }], [p1, p2]],
['le compteur abaissé à 2', compteur(2),
[{ rang: 1, id: 2, fautes: [HORS] }, { rang: 2, id: 3, fautes: [HORS] }], [p1]],
];
for (const [libelle, octet, fautives, gardees] of cas) {
const examen = unOctet(octet);
assert.deepStrictEqual(examen.fautives, fautives, libelle);
assert.deepStrictEqual(examen.charge.propositions, gardees, libelle);
assert.deepStrictEqual(examen.retenu, { fautes: [], raisons: [] }, libelle);
}
// Abaissé à 1, le compteur écarte chaque proposition, et le retenu, dont
// l'origine l'atteint, porte la faute sans être retiré. Son placement se
// tient : sa dérive se nomme encore.
const auPlusBas = unOctet(compteur(1));
assert.deepStrictEqual(
auPlusBas.fautives.map(({ id, fautes }) => [id, fautes]),
[[1, [HORS]], [2, [HORS]], [3, [HORS]]],
);
assert.deepStrictEqual(auPlusBas.charge.propositions, []);
assert.deepStrictEqual(auPlusBas.retenu, { fautes: [HORS], raisons: [] });
assert.deepStrictEqual(auPlusBas.charge.retenu, charge.retenu);
const exclue = structuredClone(charge);
exclue.participants[4].exclu = true;
exclue.prochainsIds.proposition = 1;
assert.deepStrictEqual(examiner(exclue).retenu, {
fautes: [HORS],
raisons: [{ code: 'PARTICIPANT_EXCLU', participant: 5 }],
});
// Une proposition qui se contredit se nomme par ses propres fautes,
// même quand son identifiant atteint aussi le compteur ; un retenu qui se
// contredit aussi, sans raison de dérive.
const abimee = { ...structuredClone(p3), id: 4, tours: 5 };
const contradictoire = { ...charge, propositions: [p1, abimee], retenu: { ...retenuDe(abimee), proposition: 4 } };
const longueur = { code: 'LONGUEUR', tour: null, table: null, declare: 5, ecrit: 4 };
const examen = examiner(contradictoire);
assert.deepStrictEqual(examen.fautives, [{ rang: 1, id: 4, fautes: [longueur] }]);
assert.deepStrictEqual(examen.retenu, { fautes: [longueur], raisons: [] });
});
test("la charge rendue relève prochainsIds.proposition au-delà de chaque identifiant lu — d'une proposition écartée, quelle que soit sa faute, et de l'origine du retenu, cohérent ou non — ; le compteur ne recule jamais, ce qui n'est pas un identifiant, au-delà de 2^31 − 1 compris, n'y entre pas, et relevé, il s'arrête au plus grand identifiant, que l'analyse relit", () => {
const HORS = 'IDENTIFIANT_HORS_COMPTEUR';
const PLUS_GRAND = 2 ** 31 - 1;
// Les propositions 1, 2 et 3 sous le compteur 4 ; le retenu vient de la
// troisième. Chaque cas abîme la charge du fichier écrit, que l'analyse
// relit et qui se fige avant l'examen. Il nomme les écartées, [id,
// code], et les fautes du retenu, ce que le cas éprouve, puis le
// compteur attendu.
const base = chargeAvecGeneration();
base.retenu = retenuDe(base.propositions[2]);
base.evenement.etat = 'retenu';
const cas = [
["le fichier tel qu'écrit", () => {}, [], [], 4],
['le compteur porté à 9', (c) => { c.prochainsIds.proposition = 9; }, [], [], 9],
['le compteur abaissé à 2', (c) => { c.prochainsIds.proposition = 2; }, [[2, HORS], [3, HORS]], [HORS], 4],
['le compteur abaissé à 1', (c) => { c.prochainsIds.proposition = 1; },
[[1, HORS], [2, HORS], [3, HORS]], [HORS], 4],
['la troisième à 9', (c) => { c.propositions[2].id = 9; }, [[9, HORS]], [], 10],
['la troisième à 9, qui se contredit', (c) => { Object.assign(c.propositions[2], { id: 9, tours: 5 }); },
[[9, 'LONGUEUR']], [], 10],
['la troisième répète la deuxième', (c) => { c.propositions[2].id = 2; }, [[2, 'IDENTIFIANT_REPETE']], [], 4],
['la troisième à 9.5', (c) => { c.propositions[2].id = 9.5; }, [[null, 'FORME']], [], 4],
['la troisième à 2^53', (c) => { c.propositions[2].id = 2 ** 53; }, [[null, 'FORME']], [], 4],
['la troisième à 2^31', (c) => { c.propositions[2].id = 2 ** 31; }, [[null, 'FORME']], [], 4],
['la troisième au plus grand identifiant', (c) => { c.propositions[2].id = PLUS_GRAND; },
[[PLUS_GRAND, HORS]], [], PLUS_GRAND],
["l'origine du retenu à 9", (c) => { c.retenu.proposition = 9; }, [], [HORS], 10],
["l'origine du retenu à 9, qui se contredit", (c) => { Object.assign(c.retenu, { proposition: 9, tours: 5 }); },
[], ['LONGUEUR'], 10],
["l'origine du retenu à 9, hors de sa règle", (c) => {
Object.assign(c.retenu, { proposition: 9, toura: c.retenu.tours });
delete c.retenu.tours;
}, [], ['FORME'], 10],
["l'origine du retenu à 9.5", (c) => { c.retenu.proposition = 9.5; }, [], ['FORME'], 4],
["l'origine du retenu au plus grand entier exact", (c) => { c.retenu.proposition = Number.MAX_SAFE_INTEGER; },
[], ['FORME'], 4],
["l'origine du retenu à 2^31", (c) => { c.retenu.proposition = 2 ** 31; }, [], ['FORME'], 4],
["l'origine du retenu au plus grand identifiant", (c) => { c.retenu.proposition = PLUS_GRAND; },
[], [HORS], PLUS_GRAND],
];
// Le compteur que l'analyse relit dans le fichier de la charge rendue,
// ou le refus qu'elle lève.
const compteurRelu = (charge) => {
try {
return relire(charge).prochainsIds.proposition;
} catch (erreur) {
return `${erreur.name} ${erreur.message}`;
}
};
const observes = cas.map(([libelle, abimer]) => {
const lue = geler(abimerFichier(base, (ecrit) => abimer(ecrit.charge)));
const examen = examiner(lue);
return [
libelle,
examen.fautives.map(({ id, fautes: [premiere] }) => [id, premiere.code]),
examen.retenu.fautes.map(({ code }) => code),
examen.charge.prochainsIds,
compteurRelu(examen.charge),
];
});
assert.deepStrictEqual(
observes,
cas.map(([libelle, , ecartees, fautesDuRetenu, compteur]) => [
libelle,
ecartees,
fautesDuRetenu,
{ participant: 13, table: 5, proposition: compteur },
compteur,
]),
);
});
test("un identifiant au-delà de 2^31 − 1, écrit à la main à l'origine du retenu ou à une proposition, n'en est pas un : le retenu est fautif, la proposition écartée, le compteur reste à 4 ; la génération suivante, numérotée comme le dit versFichier, prend 4, 5 et 6, et le fichier se rouvre", () => {
// Les propositions 1, 2 et 3 sous le compteur 4 ; le retenu vient de la
// première. Une valeur change dans le texte écrit, que l'analyse lit
// sans entrer dans les propositions ni dans le retenu.
const suivante = rechercher(petite(), { graine: 13, arret: 2_000, nombre: 3 });
const charge = chargeAvecGeneration();
charge.retenu = retenuDe(charge.propositions[0]);
charge.evenement.etat = 'retenu';
const texte = serialiser(charge, ENTETE);
const FORME_ORIGINE = { code: 'FORME', chemin: 'proposition' };
const cas = [
["l'origine du retenu à 2^53 − 2", '"retenu": {"proposition":1,', 2 ** 53 - 2, [1, 2, 3], [FORME_ORIGINE]],
["l'origine du retenu à 2^31", '"retenu": {"proposition":1,', 2 ** 31, [1, 2, 3], [FORME_ORIGINE]],
['la troisième proposition à 2^53 − 2', '{"id":3,"graine"', 2 ** 53 - 2, [1, 2], []],
['la troisième proposition à 2^31', '{"id":3,"graine"', 2 ** 31, [1, 2], []],
];
const observes = [];
const attendus = [];
for (const [libelle, avant, valeur, gardees, fautesDuRetenu] of cas) {
assert.equal(texte.split(avant).length, 2, `« ${avant} » doit figurer une fois`);
const ouverte = examiner(analyser(texte.replace(avant, avant.replace(/\d+,/, `${valeur},`))).charge).charge;
// L'appelant, comme le dit versFichier : la génération décalée de
// prochainsIds.proposition − 1, puis le compteur porté au-delà.
const decalage = ouverte.prochainsIds.proposition - 1;
const configuration = configurationDepuisCharge(ouverte);
const options = { produitVersion: VERSION.affichee, attribuerSieges: false, decalage };
const generee = {
...ouverte,
prochainsIds: { ...ouverte.prochainsIds, proposition: decalage + suivante.length + 1 },
propositions: [
...ouverte.propositions,
...suivante.map((proposition) => versFichier(proposition, configuration, options)),
],
};
const rouverte = examiner(relire(generee));
observes.push([
libelle,
decalage,
rouverte.charge.propositions.map(({ id }) => id),
rouverte.fautives,
rouverte.retenu.fautes,
rouverte.charge.prochainsIds.proposition,
]);
attendus.push([libelle, 3, [...gardees, 4, 5, 6], [], fautesDuRetenu, 7]);
}
assert.deepStrictEqual(observes, attendus);
});
test("un identifiant de 2^31 − 1, le plus grand, écrit à la main à l'origine du retenu ou à une proposition, ou le compteur écrit à cette valeur, laisse à 2^31 − 1 le compteur de la charge rendue : la génération suivante, d'une proposition ou de trois, numérotée comme le dit versFichier, lève COMPTEUR_SATURE à sa première proposition, un code que l'appelant lit avant toute écriture", () => {
// Les propositions 1, 2 et 3 sous le compteur 4 ; le retenu vient de la
// première. Une valeur change dans le texte écrit, que l'analyse admet :
// elle ne lit des propositions et du retenu que le conteneur, et le
// compteur reste dans sa règle.
const PLUS_GRAND = 2 ** 31 - 1;
const suivante = rechercher(petite(), { graine: 13, arret: 2_000, nombre: 3 });
const charge = chargeAvecGeneration();
charge.retenu = retenuDe(charge.propositions[0]);
charge.evenement.etat = 'retenu';
const texte = serialiser(charge, ENTETE);
const cas = [
["l'origine du retenu", '"retenu": {"proposition":1,', `"retenu": {"proposition":${PLUS_GRAND},`],
['la troisième proposition', '{"id":3,"graine"', `{"id":${PLUS_GRAND},"graine"`],
['le compteur', '"proposition":4}', `"proposition":${PLUS_GRAND}}`],
];
const observes = [];
const attendus = [];
for (const [libelle, avant, apres] of cas) {
assert.equal(texte.split(avant).length, 2, `« ${avant} » doit figurer une fois`);
const ouverte = examiner(analyser(texte.replace(avant, apres)).charge).charge;
// L'appelant, comme le dit versFichier : la génération décalée de
// prochainsIds.proposition − 1, chacune de ses propositions mise en
// forme avant qu'aucune ne s'écrive.
const decalage = ouverte.prochainsIds.proposition - 1;
const options = { produitVersion: VERSION.affichee, attribuerSieges: false, decalage };
const configuration = configurationDepuisCharge(ouverte);
for (const nombre of [1, 3]) {
const generation = suivante.slice(0, nombre);
observes.push([
libelle,
nombre,
ouverte.prochainsIds.proposition,
refusDe(() => generation.map((proposition) => versFichier(proposition, configuration, options))),
]);
attendus.push([libelle, nombre, PLUS_GRAND, COMPTEUR_SATURE]);
}
}
assert.deepStrictEqual(observes, attendus);
});
test("un compteur abaissé à la main, puis une génération numérotée depuis la charge rendue, comme le dit versFichier : aucun identifiant écarté ne revient, et l'origine du retenu n'est jamais réattribuée", () => {
// Les propositions 1, 2 et 3 sous le compteur 4 ; le retenu vient de
// l'une d'elles. Le compteur, abaissé à 1, 2 ou 3 dans le fichier écrit,
// écarte celles qu'il ne couvre plus. La génération suivante, décalée de
// prochainsIds.proposition − 1 de la charge rendue, prend 4, 5 et 6 ; à
// la réouverture, l'identifiant de l'origine du retenu ne désigne que
// l'origine elle-même, quand elle est gardée, et rien sinon.
const suivante = rechercher(petite(), { graine: 13, arret: 2_000, nombre: 3 });
const observes = [];
const attendus = [];
for (const abaisse of [1, 2, 3]) {
for (const rang of [0, 1, 2]) {
const charge = chargeAvecGeneration();
const origine = charge.propositions[rang];
charge.retenu = retenuDe(origine);
charge.evenement.etat = 'retenu';
const texte = serialiser(charge, ENTETE);
assert.equal(texte.split('"proposition":4}').length, 2, 'le compteur doit figurer une fois');
const abime = texte.replace('"proposition":4}', `"proposition":${abaisse}}`);
const ouverte = examiner(analyser(abime).charge).charge;
// L'appelant, comme le dit versFichier : la génération décalée de
// prochainsIds.proposition − 1, puis le compteur porté au-delà.
const configuration = configurationDepuisCharge(ouverte);
const decalage = ouverte.prochainsIds.proposition - 1;
const options = { produitVersion: VERSION.affichee, attribuerSieges: false, decalage };
const generee = {
...ouverte,
prochainsIds: { ...ouverte.prochainsIds, proposition: decalage + suivante.length + 1 },
propositions: [
...ouverte.propositions,
...suivante.map((proposition) => versFichier(proposition, configuration, options)),
],
};
const rouverte = examiner(relire(generee));
const libelle = `compteur abaissé à ${abaisse}, retenu de la proposition ${origine.id}`;
observes.push([
libelle,
rouverte.charge.propositions.map(({ id }) => id),
rouverte.fautives,
rouverte.retenu,
rouverte.charge.propositions.filter(({ id }) => id === origine.id).map(({ graine }) => graine),
]);
attendus.push([
libelle,
[...[1, 2, 3].filter((id) => id < abaisse), 4, 5, 6],
[],
{ fautes: [], raisons: [] },
origine.id < abaisse ? [origine.graine] : [],
]);
}
}
assert.deepStrictEqual(observes, attendus);
});
test("une proposition qui ne se lit pas est écartée seule : rang dans la liste, id null quand son identifiant n'en est pas un ; null, et un retenu égaré parmi les propositions, aussi", () => {
const charge = chargeAvecGeneration();
const lue = abimerFichier(charge, (ecrit) => {
const [, deuxieme] = ecrit.charge.propositions;
ecrit.charge.propositions.unshift(42, { id: 1, inconnue: true }, { ...deuxieme, id: 'deux' }, null, retenuDe(deuxieme));
ecrit.entete.comptes.propositions = 8;
});
const examen = examiner(lue);
// Le retenu égaré passe la forme du retenu : seule la règle d'une
// proposition, que l'examen applique à chaque élément de la liste,
// l'écarte.
assert.deepStrictEqual(fautes(lue.propositions[4]), []);
assert.deepStrictEqual(examen.fautives, [
{ rang: 0, id: null, fautes: [{ code: 'FORME', chemin: '' }] },
{ rang: 1, id: 1, fautes: [{ code: 'FORME', chemin: 'graine' }] },
{ rang: 2, id: null, fautes: [{ code: 'FORME', chemin: 'id' }] },
{ rang: 3, id: null, fautes: [{ code: 'FORME', chemin: '' }] },
{ rang: 4, id: null, fautes: [{ code: 'FORME', chemin: 'id' }] },
]);
assert.deepStrictEqual(examen.charge.propositions, charge.propositions);
assert.deepStrictEqual(examen.derives, []);
});
test("la charge rendue garde l'ordre de la liste ; les dérives se rangent par identifiant, les fautives par rang", () => {
const charge = chargeAvecGeneration();
const [p1, p2, p3] = charge.propositions;
const abimee = { ...structuredClone(p2), id: 4, tours: 5 };
charge.propositions = [p3, abimee, p1, p2];
charge.participants[4].exclu = true;
const examen = examiner(charge);
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 5 }];
assert.deepStrictEqual(examen.charge.propositions, [p3, p1, p2]);
assert.deepStrictEqual(examen.derives, [1, 2, 3].map((id) => ({ id, raisons })));
assert.deepStrictEqual(examen.fautives.map(({ rang, id }) => [rang, id]), [[1, 4]]);
});
test('examiner ne modifie pas la charge reçue et rend une charge neuve, dont le reste est celui de la charge reçue', () => {
const charge = chargeAvecGeneration();
charge.propositions[0].placement[0].reserve.push(13);
charge.retenu = retenuDe(charge.propositions[1]);
charge.participants[4].exclu = true;
const avant = structuredClone(charge);
geler(charge);
const examen = examiner(charge);
assert.deepStrictEqual(charge, avant);
assert.notEqual(examen.charge, charge);
assert.deepStrictEqual(examen.charge, { ...avant, propositions: avant.propositions.slice(1) });
assert.equal(examen.charge.participants, charge.participants);
assert.equal(examen.charge.retenu, charge.retenu);
});
});
describe('formeNommee : la lecture et le signalement (§ 8.9, point 4)', () => {
test('le tour 1 du plan parfait : douze lignes, table 1 → 1, 2, 3, avec les noms et prénoms de la charge, siège null sans attribution', () => {
const lignes = formeNommee(fichierParfait(), chargePetite());
assert.equal(lignes.length, 48);
assert.deepStrictEqual(lignes.filter(({ tour }) => tour === 1), [
ligne(1, 1, 1, null, 1, 'Aubépine', 'Agathe'),
ligne(1, 1, 1, null, 2, 'Bruyère', 'Basile'),
ligne(1, 1, 1, null, 3, 'Chardon', 'Céleste'),
ligne(1, 2, 2, null, 4, 'Doucette', 'Désiré'),
ligne(1, 2, 2, null, 5, 'Églantine', 'Edmée'),
ligne(1, 2, 2, null, 6, 'Fougère', 'Firmin'),
ligne(1, 3, 3, null, 7, 'Giroflée', 'Gisèle'),
ligne(1, 3, 3, null, 8, 'Houblon', 'Honoré'),
ligne(1, 3, 3, null, 9, 'Isatis', 'Ida'),
ligne(1, 4, 4, null, 10, 'Jonquille', 'Joachim'),
ligne(1, 4, 4, null, 11, 'Lavande', 'Lazare'),
ligne(1, 4, 4, null, 12, 'Mélisse', 'Mathurin'),
]);
assert.deepStrictEqual(
lignes.map(({ tour, numero, participant }) => [tour, numero, participant]),
PLAN_PARFAIT_PETITE.tours.flatMap((listes, r) => listes.flatMap((ids, i) => ids.map((id) => [r + 1, i + 1, id]))),
);
});
test("sièges attribués : le siège est le rang dans la liste, à partir de 1, dans l'ordre écrit", () => {
const p = { ...fichierParfait(), siegesAttribues: true };
p.placement[0].sieges[0] = [3, 1, 2];
const lignes = formeNommee(p, chargePetite()).filter(({ tour, table: id }) => tour === 1 && id === 1);
assert.deepStrictEqual(lignes, [
ligne(1, 1, 1, 1, 3, 'Chardon', 'Céleste'),
ligne(1, 1, 1, 2, 1, 'Aubépine', 'Agathe'),
ligne(1, 1, 1, 3, 2, 'Bruyère', 'Basile'),
]);
});
test("lignes rangées par tour, numéro de table, rang : numéros égaux départagés par l'identifiant, une table supprimée après les autres sans numéro, la réserve en dernier, une personne supprimée sans nom", () => {
const p = fichierPermute();
p.participants.push(13);
for (const tour of p.placement) tour.reserve = [13];
const charge = chargePetite();
charge.participants.push(personne(13, ...TREIZIEME));
charge.participants[6].prenom = null;
charge.participants.splice(11, 1);
charge.prochainsIds.participant = 14;
charge.tables = [table(1, 3), table(2, 1), table(3, 1)];
const lignes = formeNommee(p, charge);
assert.equal(lignes.length, 52);
assert.deepStrictEqual(lignes.filter(({ tour }) => tour === 1), [
ligne(1, 2, 1, null, 4, 'Doucette', 'Désiré'),
ligne(1, 2, 1, null, 5, 'Églantine', 'Edmée'),
ligne(1, 2, 1, null, 6, 'Fougère', 'Firmin'),
ligne(1, 3, 1, null, 7, 'Giroflée', null),
ligne(1, 3, 1, null, 8, 'Houblon', 'Honoré'),
ligne(1, 3, 1, null, 9, 'Isatis', 'Ida'),
ligne(1, 1, 3, null, 1, 'Aubépine', 'Agathe'),
ligne(1, 1, 3, null, 2, 'Bruyère', 'Basile'),
ligne(1, 1, 3, null, 3, 'Chardon', 'Céleste'),
ligne(1, 4, null, null, 10, 'Jonquille', 'Joachim'),
ligne(1, 4, null, null, 11, 'Lavande', 'Lazare'),
ligne(1, 4, null, null, 12, null, null),
ligne(1, null, null, null, 13, 'Nigelle', 'Noémie'),
]);
assert.deepStrictEqual(
lignes.map(({ tour }) => tour),
[1, 2, 3, 4].flatMap((tour) => Array(13).fill(tour)),
);
});
test("la réserve suit les tables de son tour, ses personnes dans l'ordre écrit", () => {
const lignes = formeNommee(fichierAReserve(), chargePetite()).filter(({ tour, table: id }) => tour === 1 && id === null);
assert.deepStrictEqual(lignes, [
ligne(1, null, null, null, 6, 'Fougère', 'Firmin'),
ligne(1, null, null, null, 3, 'Chardon', 'Céleste'),
]);
});
test("planDepuisFichier, derive et formeNommee refusent une proposition fautive par TypeError, au lieu d'en rendre une lecture fausse ; fautes ne lève jamais", () => {
const charge = chargePetite();
const abimee = fichierParfait();
abimee.placement[0].sieges[0].pop();
for (const [nom, appel] of [
['planDepuisFichier', () => planDepuisFichier(abimee)],
['derive', () => derive(abimee, charge)],
['formeNommee', () => formeNommee(abimee, charge)],
]) {
leveTypeError(appel, /"MANQUANT"/, nom);
}
for (const illisible of [42, undefined, { ...fichierParfait(), placement: 'x' }]) {
leveTypeError(() => planDepuisFichier(illisible), /"FORME"/, String(illisible));
leveTypeError(() => derive(illisible, charge), /"FORME"/, String(illisible));
leveTypeError(() => formeNommee(illisible, charge), /"FORME"/, String(illisible));
}
assert.deepStrictEqual(fautes(undefined), [{ code: 'FORME', chemin: '' }]);
});
});
// Exécute fonction pendant que l'horloge et les sources d'aléa lèvent à leur
// lecture, puis les rétablit telles qu'elles étaient, propres ou héritées ;
// rend ce que rend fonction.
function sansHorlogeNiAlea(fonction) {
const interdite = (nom) => () => {
throw new Error(`${nom} lu`);
};
const remplacees = [
[Math, 'random'],
[Date, 'now'],
[performance, 'now'],
[crypto, 'getRandomValues'],
[crypto, 'randomUUID'],
].map(([objet, nom]) => ({ objet, nom, propre: Object.hasOwn(objet, nom), valeur: objet[nom] }));
const DateReelle = globalThis.Date;
for (const { objet, nom } of remplacees) objet[nom] = interdite(nom);
function DateInterdite() {
throw new Error('Date lu');
}
DateInterdite.now = interdite('Date.now');
globalThis.Date = DateInterdite;
try {
return fonction();
} finally {
globalThis.Date = DateReelle;
for (const { objet, nom, propre, valeur } of remplacees) {
if (propre) objet[nom] = valeur;
else delete objet[nom];
}
}
}
// La valeur refaite à toute profondeur, les clés de chaque objet à rebours.
function aRebours(valeur) {
if (Array.isArray(valeur)) return valeur.map(aRebours);
if (typeof valeur !== 'object' || valeur === null) return valeur;
const copie = {};
for (const cle of Object.keys(valeur).reverse()) copie[cle] = aRebours(valeur[cle]);
return copie;
}
describe('déterminisme (§ 8.8, § 14.7)', () => {
// Les résultats de chaque fonction du module sur une charge où se trouvent
// une proposition fautive, une dérive et un retenu, en un seul texte.
function resultats(charge) {
const configuration = petite();
const [, coherente] = charge.propositions;
const examen = examiner(charge);
return JSON.stringify([
versFichier(GENERATION[0], configuration, { produitVersion: VERSION.affichee, attribuerSieges: false, decalage: 0 }),
charge.propositions.map((proposition) => fautes(proposition)),
derive(coherente, charge),
formeNommee(coherente, charge),
planDepuisFichier(coherente),
[examen.fautives, examen.derives, examen.retenu, examen.charge.propositions.map(({ id }) => id)],
]);
}
function chargeMelee() {
const charge = chargeAvecGeneration();
charge.propositions[0].placement[0].reserve.push(13, 2);
charge.propositions[2].placement[3].sieges[1].pop();
charge.retenu = retenuDe(charge.propositions[1]);
charge.participants[4].exclu = true;
return charge;
}
test('ni horloge ni aléa : la sortie de chaque fonction ne dépend que de ses arguments', () => {
// Le filet est éprouvé d'abord : chaque source lève pendant qu'il est tendu.
for (const lecture of [
() => Math.random(),
() => Date.now(),
() => new Date(),
() => performance.now(),
() => crypto.getRandomValues(new Uint8Array(1)),
() => crypto.randomUUID(),
]) {
assert.throws(() => sansHorlogeNiAlea(lecture), / lu$/);
}
assert.equal(typeof Date.now(), 'number');
assert.equal(sansHorlogeNiAlea(() => resultats(chargeMelee())), resultats(chargeMelee()));
});
test("l'ordre des clés reçues n'entre dans aucun résultat, ni dans la clé inconnue nommée", () => {
const charge = chargeMelee();
const inversee = aRebours(charge);
assert.notEqual(JSON.stringify(inversee), JSON.stringify(charge));
assert.equal(resultats(inversee), resultats(charge));
const p = fichierParfait();
const zetaPuisAlpha = { ...p, zeta: 1, alpha: 2 };
const alphaPuisZeta = { ...p, alpha: 2, zeta: 1 };
assert.deepStrictEqual(fautes(zetaPuisAlpha), [{ code: 'FORME', chemin: 'alpha' }]);
assert.deepStrictEqual(fautes(alphaPuisZeta), [{ code: 'FORME', chemin: 'alpha' }]);
});
});