gestion_table_tournante_libre/src/stockage/placements.test.js

1498 lines
77 KiB
JavaScript
Raw Normal View History

// © 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';
2026-10-06 19:22:02 -04:00
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);
}
2026-10-06 19:22:02 -04:00
// 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' }) });
[FIX] storage, session: strict UTF-8, lost journal, lock, exclusions The whole-iteration review found ways to lose or corrupt data. A state saved in another encoding opened with its accents turned to U+FFFD and was then overwritten; decoding is now strict, the state opens read-only, and a gesture from its secours first moves it to the trash. A journal decodes line by line, so one bad byte drops only the lines after it. A vanished journal is rewritten whole, never appended to. Deleting refuses an event another session holds; switching to writing rereads the disk; an import that changes exclu says which reservations it suspends. Checked: 1504 node, 35 browser, 29 node-long; depot.js, journal.js 100 %. --- FR --- [FIX] stockage, séance : UTF-8 strict, journal perdu, verrou, exclusions La revue d'ensemble a trouvé des façons de perdre ou d'abîmer des données. Un état enregistré dans un autre encodage s'ouvrait, ses accents changés en U+FFFD, puis s'écrasait ; le décodage est strict, l'état s'ouvre en lecture seule, et un geste depuis son secours le range d'abord à la corbeille. Le journal se décode ligne par ligne : un octet fautif n'écarte que la suite. Un journal disparu se réécrit en entier, jamais par ajout. Supprimer refuse un événement qu'une autre séance tient ; passer en écriture relit le disque ; un import qui change exclu dit quelles réservations il suspend. Vérifié : 1504 node, 35 navigateur, 29 node-long ; depot.js, journal.js 100 %. Assisted-by: Claude Opus 5.5
2026-10-07 01:20:42 -04:00
// Une ligne attendue de la forme nommée : sans table, une ligne de la
// réserve, sauf quand enReserve dit le contraire.
const ligne = (tour, idTable, numero, siege, participant, nom, prenom, enReserve = idTable === null) => ({
tour,
table: idTable,
numero,
siege,
participant,
nom,
prenom,
[FIX] storage, session: strict UTF-8, lost journal, lock, exclusions The whole-iteration review found ways to lose or corrupt data. A state saved in another encoding opened with its accents turned to U+FFFD and was then overwritten; decoding is now strict, the state opens read-only, and a gesture from its secours first moves it to the trash. A journal decodes line by line, so one bad byte drops only the lines after it. A vanished journal is rewritten whole, never appended to. Deleting refuses an event another session holds; switching to writing rereads the disk; an import that changes exclu says which reservations it suspends. Checked: 1504 node, 35 browser, 29 node-long; depot.js, journal.js 100 %. --- FR --- [FIX] stockage, séance : UTF-8 strict, journal perdu, verrou, exclusions La revue d'ensemble a trouvé des façons de perdre ou d'abîmer des données. Un état enregistré dans un autre encodage s'ouvrait, ses accents changés en U+FFFD, puis s'écrasait ; le décodage est strict, l'état s'ouvre en lecture seule, et un geste depuis son secours le range d'abord à la corbeille. Le journal se décode ligne par ligne : un octet fautif n'écarte que la suite. Un journal disparu se réécrit en entier, jamais par ajout. Supprimer refuse un événement qu'une autre séance tient ; passer en écriture relit le disque ; un import qui change exclu dit quelles réservations il suspend. Vérifié : 1504 node, 35 navigateur, 29 node-long ; depot.js, journal.js 100 %. Assisted-by: Claude Opus 5.5
2026-10-07 01:20:42 -04:00
reserve: enReserve,
});
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');
});
2026-10-06 19:22:02 -04:00
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]'],
2026-10-06 19:22:02 -04:00
// 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: [] });
});
2026-10-06 19:22:02 -04:00
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';
2026-10-06 19:22:02 -04:00
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],
2026-10-06 19:22:02 -04:00
['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],
2026-10-06 19:22:02 -04:00
["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,
]),
);
});
2026-10-06 19:22:02 -04:00
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'),
]);
});
[FIX] storage, session: strict UTF-8, lost journal, lock, exclusions The whole-iteration review found ways to lose or corrupt data. A state saved in another encoding opened with its accents turned to U+FFFD and was then overwritten; decoding is now strict, the state opens read-only, and a gesture from its secours first moves it to the trash. A journal decodes line by line, so one bad byte drops only the lines after it. A vanished journal is rewritten whole, never appended to. Deleting refuses an event another session holds; switching to writing rereads the disk; an import that changes exclu says which reservations it suspends. Checked: 1504 node, 35 browser, 29 node-long; depot.js, journal.js 100 %. --- FR --- [FIX] stockage, séance : UTF-8 strict, journal perdu, verrou, exclusions La revue d'ensemble a trouvé des façons de perdre ou d'abîmer des données. Un état enregistré dans un autre encodage s'ouvrait, ses accents changés en U+FFFD, puis s'écrasait ; le décodage est strict, l'état s'ouvre en lecture seule, et un geste depuis son secours le range d'abord à la corbeille. Le journal se décode ligne par ligne : un octet fautif n'écarte que la suite. Un journal disparu se réécrit en entier, jamais par ajout. Supprimer refuse un événement qu'une autre séance tient ; passer en écriture relit le disque ; un import qui change exclu dit quelles réservations il suspend. Vérifié : 1504 node, 35 navigateur, 29 node-long ; depot.js, journal.js 100 %. Assisted-by: Claude Opus 5.5
2026-10-07 01:20:42 -04:00
test("un placement incohérent se rend pour le signalement de son défaut, une ligne par identifiant écrit, ses fautes à côté (fautes) : doublon, inconnu, liste au-delà de sa capacité, liste en trop sans table, liste ou tour en moins", () => {
const charge = chargePetite();
const casDe = (abimer) => {
const p = fichierParfait();
abimer(p);
return p;
};
const cas = [
[
'un assis aussi dans la réserve',
casDe((p) => { p.placement[0].reserve = [2]; }),
['DOUBLON'],
(lignes) => {
assert.equal(lignes.length, 49);
assert.deepEqual(lignes.filter(({ tour, participant }) => tour === 1 && participant === 2), [
ligne(1, 1, 1, null, 2, 'Bruyère', 'Basile'),
ligne(1, null, null, null, 2, 'Bruyère', 'Basile'),
]);
},
],
[
"un inconnu à la place d'un déclaré",
casDe((p) => { p.placement[1].sieges[0][0] = 99; }),
['INCONNU', 'MANQUANT'],
(lignes) => {
assert.equal(lignes.length, 48);
assert.deepEqual(lignes.filter(({ participant }) => participant === 99), [
ligne(2, 1, 1, null, 99, null, null),
]);
},
],
[
'une table au-delà de sa capacité, une liste en trop',
casDe((p) => {
p.siegesAttribues = true;
p.placement[0].sieges[0].push(4);
p.placement[0].sieges.push([5, 6]);
}),
['LONGUEUR', 'LONGUEUR', 'DOUBLON', 'DOUBLON', 'DOUBLON'],
(lignes) => {
assert.equal(lignes.length, 51);
assert.deepEqual(lignes.filter(({ tour }) => tour === 1).slice(0, 4).map(({ siege, participant }) => [siege, participant]), [
[1, 1],
[2, 2],
[3, 3],
[4, 4],
]);
assert.deepEqual(lignes.filter(({ tour }) => tour === 1).slice(-2), [
ligne(1, null, null, 1, 5, 'Églantine', 'Edmée', false),
ligne(1, null, null, 2, 6, 'Fougère', 'Firmin', false),
]);
},
],
[
'une liste et un tour en moins',
casDe((p) => {
p.placement[0].sieges.pop();
p.placement.pop();
}),
['LONGUEUR', 'LONGUEUR', 'MANQUANT', 'MANQUANT', 'MANQUANT'],
(lignes) => {
assert.equal(lignes.length, 33);
assert.deepEqual([...new Set(lignes.map(({ tour }) => tour))], [1, 2, 3]);
assert.equal(lignes.filter(({ tour, table }) => tour === 1 && table === 4).length, 0);
},
],
];
for (const [nom, p, codes, verifier] of cas) {
assert.deepEqual(fautes(p).map(({ code }) => code), codes, nom);
verifier(formeNommee(p, charge));
}
});
test("planDepuisFichier et derive refusent une proposition fautive par TypeError, au lieu d'en rendre une lecture fausse ; formeNommee ne refuse qu'une forme 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)],
]) {
leveTypeError(appel, /"MANQUANT"/, nom);
}
[FIX] storage, session: strict UTF-8, lost journal, lock, exclusions The whole-iteration review found ways to lose or corrupt data. A state saved in another encoding opened with its accents turned to U+FFFD and was then overwritten; decoding is now strict, the state opens read-only, and a gesture from its secours first moves it to the trash. A journal decodes line by line, so one bad byte drops only the lines after it. A vanished journal is rewritten whole, never appended to. Deleting refuses an event another session holds; switching to writing rereads the disk; an import that changes exclu says which reservations it suspends. Checked: 1504 node, 35 browser, 29 node-long; depot.js, journal.js 100 %. --- FR --- [FIX] stockage, séance : UTF-8 strict, journal perdu, verrou, exclusions La revue d'ensemble a trouvé des façons de perdre ou d'abîmer des données. Un état enregistré dans un autre encodage s'ouvrait, ses accents changés en U+FFFD, puis s'écrasait ; le décodage est strict, l'état s'ouvre en lecture seule, et un geste depuis son secours le range d'abord à la corbeille. Le journal se décode ligne par ligne : un octet fautif n'écarte que la suite. Un journal disparu se réécrit en entier, jamais par ajout. Supprimer refuse un événement qu'une autre séance tient ; passer en écriture relit le disque ; un import qui change exclu dit quelles réservations il suspend. Vérifié : 1504 node, 35 navigateur, 29 node-long ; depot.js, journal.js 100 %. Assisted-by: Claude Opus 5.5
2026-10-07 01:20:42 -04:00
assert.equal(formeNommee(abimee, charge).length, 47);
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' }]);
});
});