gestion_table_tournante_libre/src/stockage/correctifs.test.js
Mathieu Benoit 03c04bdf16 [ADD] storage: empty chair in a table list when seats are assigned
The operator plans, the room is not tracked live: removing a person from
the retained plan must free her chair and leave every other seat where it
is. With seats assigned, a table list may now hold null before its last
occupant, seat 1 included, and never end on it; without assignment no
list holds null. Validation, canonical form, patches and the named form
handle it, and the delivered demos keep their bytes.
Checked: 1549 node and 58 node-long tests; depot.js, journal.js at 100 %.

--- FR ---

[ADD] stockage : chaise vide dans une liste de table aux sièges attribués

L'opérateur planifie, la salle ne se suit pas en direct : retirer une
personne du plan retenu doit libérer sa chaise et laisser tout autre siège
à sa place. Sièges attribués, une liste de table peut porter null avant son
dernier occupant, siège 1 compris, jamais à la fin ; sans attribution,
aucune liste ne porte null. Contrôle, forme canonique, correctifs et forme
nommée le traitent, et les démonstrations livrées gardent leurs octets.
Vérifié : 1549 node et 58 node-long ; depot.js, journal.js à 100 %.

Assisted-by: Claude Opus 5.5
2026-10-07 02:31:26 -04:00

1751 lines
83 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 correctifs du journal (§ 8.6, § 8.9) : l'aller-retour
// appliquer(a, difference(a, b)) sur des paires de charges tirées, la
// localité sur la grande démonstration, chaque règle de difference, la copie
// que rend appliquer, et ses refus, chacun avec le rang de l'opération
// refusée. Les charges des démonstrations se construisent ici, en forme
// positionnelle, sans le module des placements : la configuration vient du
// catalogue, chaque proposition d'une distribution tournante. Ce module ne
// sert qu'à reconnaître un retenu abîmé, et à examiner un fichier qui en
// porte un comme le dépôt l'ouvre. Les noms des charges tirées et des
// retouches sont inventés.
import assert from 'node:assert/strict';
import fc from 'fast-check';
import { describe, test } from '../../test/lanceur.js';
import { versionVoisine } from '../../test/version_voisine.js';
import { CATALOGUE } from '../demo/catalogue.js';
import { VERSION } from '../version.genere.js';
import { canoniser, serialiser, serialiserCharge } from './canonique.js';
import { appliquer, difference } from './correctifs.js';
import { analyser, creerCharge } from './document.js';
import { ErreurStockage } from './erreurs.js';
import { examiner, fautes } from './placements.js';
// En-tête sous lequel une charge d'épreuve s'écrit pour être relue.
const ENTETE = Object.freeze({ revision: 1, produitVersion: VERSION.affichee });
// Graine et nombre de tirages de la propriété, écrits ici pour que chaque
// exécution tire les mêmes paires (§ 14.12).
const GRAINE = 80_609;
const TIRAGES = 300;
const croissant = (x, y) => x - y;
const copie = (valeur) => structuredClone(valeur);
const octets = (texte) => new TextEncoder().encode(texte).length;
// Chaque objet et chaque liste que porte valeur, elle comprise :
// JSON.stringify présente chacun à la fonction de remplacement avant de
// l'écrire.
function objetsDe(valeur) {
const objets = new Set();
JSON.stringify(valeur, (_, enfant) => {
if (typeof enfant === 'object' && enfant !== null) objets.add(enfant);
return enfant;
});
return objets;
}
// Gèle valeur et tout ce qu'elle porte : une écriture y lève TypeError, le
// module s'exécutant en mode strict.
function geler(valeur) {
for (const objet of objetsDe(valeur)) Object.freeze(objet);
return valeur;
}
// Détails du refus CORRECTIF de appliquer(charge, correctif), ou un texte
// qui dit l'écart : une autre erreur, un autre code, aucun refus.
function refusDe(charge, correctif) {
try {
appliquer(charge, correctif);
} catch (erreur) {
if (!(erreur instanceof ErreurStockage)) {
return `${erreur?.name} au lieu d'une ErreurStockage : ${erreur?.message}`;
}
if (erreur.code !== 'CORRECTIF') return `code ${erreur.code} au lieu de CORRECTIF`;
return erreur.details;
}
return 'aucun refus';
}
// Chaque valeur que porte valeur, elle exclue, { chemin, valeur }, dans
// l'ordre où JSON.stringify les écrit, celui du schéma sur une copie
// canonique : une clé mène au champ d'un objet, un rang à l'élément d'une
// liste. La fonction de remplacement reçoit chaque enfant avec, pour this,
// son porteur, rencontré avant lui ; valeur elle-même a pour porteur l'objet
// qui l'enveloppe, et le chemin vide.
function valeursDe(valeur) {
const chemins = new Map();
const releves = [];
JSON.stringify(valeur, function relever(cle, enfant) {
const chemin = chemins.has(this) ? [...chemins.get(this), Array.isArray(this) ? Number(cle) : cle] : [];
if (typeof enfant === 'object' && enfant !== null) chemins.set(enfant, chemin);
if (chemin.length > 0) releves.push({ chemin, valeur: enfant });
return enfant;
});
return releves;
}
// Chemin de chaque liste que porte valeur, dans l'ordre de valeursDe.
const cheminsDesListes = (valeur) =>
valeursDe(valeur).flatMap(({ chemin, valeur: enfant }) => (Array.isArray(enfant) ? [chemin] : []));
// --- Les démonstrations, en forme positionnelle ---------------------------
// Les n premiers entiers ≥ 1 premiers avec total, croissants.
function premiersAvec(total, n) {
const pgcd = (x, y) => (y === 0 ? x : pgcd(y, x % y));
const rendus = [];
for (let m = 1; rendus.length < n; m += 1) if (pgcd(m, total) === 1) rendus.push(m);
return rendus;
}
// Proposition de la configuration en forme positionnelle (§ 8.9), d'une
// distribution tournante. Au tour r, une file range les N identifiants
// croissants par le pas du tour, r-ième entier premier avec N : la place i
// de la file reçoit l'identifiant de rang (i × pas) mod N. La file se
// distribue une personne par table, dans l'ordre des tables et en boucle,
// une table pleine passée ; qui ne trouve plus de siège va à la réserve.
// Sans attribution des sièges, chaque liste de table se trie par
// identifiant ; avec, l'ordre de la distribution fait l'ordre des sièges.
function propositionTournante(configuration, { id, siegesAttribues }) {
const ids = configuration.participants.map((p) => p.id).sort(croissant);
const capacites = configuration.tables.map((t) => t.capacite);
const placement = premiersAvec(ids.length, configuration.tours).map((pas) => {
const sieges = capacites.map(() => []);
const reserve = [];
let table = 0;
for (let i = 0; i < ids.length; i += 1) {
let passees = 0;
while (passees < capacites.length && sieges[table].length === capacites[table]) {
table = (table + 1) % capacites.length;
passees += 1;
}
if (passees === capacites.length) {
reserve.push(ids[(i * pas) % ids.length]);
} else {
sieges[table].push(ids[(i * pas) % ids.length]);
table = (table + 1) % capacites.length;
}
}
return {
sieges: siegesAttribues ? sieges : sieges.map((liste) => liste.sort(croissant)),
reserve: reserve.sort(croissant),
};
});
return {
id,
graine: 48_271 * id,
arret: 200_000,
historique: 1_000,
produitVersion: ENTETE.produitVersion,
siegesAttribues,
tables: configuration.tables.map((t) => t.id),
capacites,
tours: configuration.tours,
participants: ids,
placement,
};
}
// Charge d'une démonstration du catalogue (§ 15) : sa configuration en
// champs nommés, chaque table au défaut de l'événement quand sa capacité y
// est égale, ses réservations sans siège, et une proposition tournante par
// demande { id, siegesAttribues }. Une charge neuve à chaque appel.
function chargeDe(cle, demandes) {
const entree = CATALOGUE.find((candidate) => candidate.cle === cle);
const configuration = entree.construire();
const siegesParDefaut = Math.max(...configuration.tables.map((t) => t.capacite));
const charge = creerCharge({ id: `demo-${cle}`, nom: entree.nom, siegesParDefaut, tours: configuration.tours });
charge.evenement.etat = 'propose';
charge.prochainsIds = {
participant: Math.max(...configuration.participants.map((p) => p.id)) + 1,
table: Math.max(...configuration.tables.map((t) => t.id)) + 1,
proposition: Math.max(0, ...demandes.map(({ id }) => id)) + 1,
};
charge.participants = configuration.participants.map(({ id, nom, prenom, appartenance }) => ({
id,
nom,
prenom,
appartenance,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
}));
charge.tables = configuration.tables.map(({ id, numero, capacite }, rang) => ({
id,
numero,
sieges: capacite === siegesParDefaut ? null : capacite,
forme: 'ronde',
position: { x: 250 * (rang % 6), y: 250 * Math.floor(rang / 6) },
}));
charge.reservations = configuration.reservations.map(({ participant, table, portee, tour = null }) => ({
participant,
table,
siege: null,
portee,
tour,
}));
charge.propositions = demandes.map((demande) => propositionTournante(configuration, demande));
return charge;
}
// La grande démonstration (§ 15.1) : 260 personnes à 33 tables pleines,
// 4 tours, et trois propositions dont les identifiants ne sont pas leurs
// rangs ; la proposition 7 a ses sièges attribués.
const grande = () =>
chargeDe('grande', [
{ id: 4, siegesAttribues: false },
{ id: 7, siegesAttribues: true },
{ id: 9, siegesAttribues: false },
]);
// La petite démonstration (§ 15.3) : 12 personnes, 4 tables de 3, aucune
// réservation, une proposition sans attribution des sièges.
const petite = () => chargeDe('petite', [{ id: 1, siegesAttribues: false }]);
// Identifiant de la première personne sans réservation d'une charge.
function sansReservation(charge, aPartirDe = 1) {
const reserves = new Set(charge.reservations.map((r) => r.participant));
return charge.participants.find((p) => p.id >= aPartirDe && !reserves.has(p.id)).id;
}
// --- Les paires tirées ----------------------------------------------------
const IDS_PARTICIPANTS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const IDS_TABLES = [1, 2, 3, 4, 5];
// Plus grand identifiant de proposition tiré.
const ID_PROPOSITION_MAX = 3;
const NOMS_INVENTES = ['Brindille', 'Calembour', 'Dentelle', 'Escarbille', 'Falbala', 'Girouette'];
const PRENOMS_INVENTES = ['Azur', 'Céladon', 'Nacre', 'Opale'];
const APPARTENANCES = ['Cercle du Héron', 'Club Lanterne', 'Atelier Boussole'];
const LIBELLES = ['animation', 'accueil', 'secrétariat'];
// Une valeur ou null : fast-check tire null une fois sur freq, ici une fois
// sur deux.
const ouNul = (arbitraire) => fc.option(arbitraire, { nil: null, freq: 2 });
// Deux tirages indépendants d'un même arbitraire : les deux variantes d'un
// élément du vivier.
const deuxFois = (arbitraire) => fc.tuple(arbitraire, arbitraire);
// Les champs de saisie se tirent parmi peu de valeurs : deux variantes d'un
// même enregistrement coïncident souvent champ par champ.
const unParticipant = (id) =>
fc.record({
id: fc.constant(id),
nom: fc.constantFrom(...NOMS_INVENTES),
prenom: ouNul(fc.constantFrom(...PRENOMS_INVENTES)),
appartenance: ouNul(fc.constantFrom(...APPARTENANCES)),
courriel: ouNul(fc.constantFrom('a@exemple.test', 'b@exemple.test')),
titrePressenti: ouNul(fc.constantFrom(...LIBELLES)),
notes: ouNul(fc.constantFrom('arrive tard', 'part tôt')),
exclu: fc.boolean(),
});
const coordonnee = fc.oneof(fc.integer({ min: -400, max: 400 }), fc.double({ min: -1e4, max: 1e4, noNaN: true }));
const uneTable = (id) =>
fc.record({
id: fc.constant(id),
numero: fc.integer({ min: 1, max: 9 }),
sieges: ouNul(fc.integer({ min: 2, max: 5 })),
forme: fc.constantFrom('ronde', 'carree'),
position: fc.record({ x: coordonnee, y: coordonnee }),
});
const unEvenement = fc.record({
id: fc.constantFrom('evt-heron', 'evt-lanterne'),
nom: fc.constantFrom('Soirée des Hérons', 'Banquet Lanterne'),
date: ouNul(fc.constantFrom('2031-05-17', '2032-02-29')),
siegesParDefaut: fc.integer({ min: 2, max: 4 }),
tours: fc.integer({ min: 1, max: 3 }),
unite: fc.constant('cm'),
etat: fc.constantFrom('brouillon', 'propose', 'retenu', 'bloque'),
filiation: ouNul(
fc.record({
source: fc.record({ id: fc.constant('evt-source'), nom: fc.constant('Soirée source') }),
instant: fc.record({
revision: fc.integer({ min: 1, max: 60 }),
libelle: fc.constantFrom('Création', 'Ajouté Azur Brindille'),
}),
}),
),
});
const desReglages = fc.record({
separerAppartenances: fc.boolean(),
nouveauxVoisins: fc.boolean(),
nouvelleTable: fc.boolean(),
varierAppartenances: fc.boolean(),
attribuerSieges: fc.boolean(),
generation: fc.record({
nombre: fc.integer({ min: 1, max: 20 }),
arret: fc.constantFrom(20_000, 200_000),
historique: fc.constantFrom(500, 1_000),
}),
});
// Réservations et titres candidats : une charge garde ceux dont la
// personne, la table et le siège y existent, et le tour y tient.
const uneReservation = fc
.record({
participant: fc.constantFrom(...IDS_PARTICIPANTS),
table: fc.constantFrom(...IDS_TABLES),
siege: ouNul(fc.integer({ min: 1, max: 5 })),
portee: fc.constantFrom('tous', 'tour'),
tour: fc.integer({ min: 1, max: 3 }),
})
.map((reservation) => (reservation.portee === 'tous' ? { ...reservation, tour: null } : reservation));
const unTitre = fc.record({
table: fc.constantFrom(...IDS_TABLES),
siege: fc.integer({ min: 1, max: 5 }),
libelle: fc.constantFrom(...LIBELLES),
});
// Un tour : chaque participant, dans l'ordre d'une file mélangée, va à l'une
// des tables ou, au rang nombreDeTables, à la réserve. Les listes gardent
// l'ordre de la file, qui n'est pas l'ordre canonique.
const unTour = (participants, nombreDeTables) =>
fc
.tuple(
fc.shuffledSubarray(participants, { minLength: participants.length, maxLength: participants.length }),
fc.array(fc.integer({ min: 0, max: nombreDeTables }), {
minLength: participants.length,
maxLength: participants.length,
}),
)
.map(([file, places]) => {
const sieges = Array.from({ length: nombreDeTables }, () => []);
const reserve = [];
file.forEach((personne, rang) => {
(places[rang] === nombreDeTables ? reserve : sieges[places[rang]]).push(personne);
});
return { sieges, reserve };
});
// Une proposition de une à quatre tables du vivier, qui place une partie
// des participants du vivier, sans égard aux capacités : le contrôle des
// placements l'examine à part (§ 8.9), et l'analyse ne regarde que la
// liste. Les identifiants se tirent de 1 à ID_PROPOSITION_MAX : deux
// propositions d'une charge peuvent partager le leur, ce que la lecture
// admet, et prochainsIds.proposition reste au-delà de chacun.
const uneProposition = fc
.record({
id: fc.integer({ min: 1, max: ID_PROPOSITION_MAX }),
graine: fc.integer({ min: 0, max: 2 ** 32 - 1 }),
arret: fc.constantFrom(20_000, 200_000),
historique: fc.constantFrom(500, 1_000),
produitVersion: fc.constantFrom(VERSION.affichee, versionVoisine(VERSION.affichee)),
siegesAttribues: fc.boolean(),
tables: fc.uniqueArray(fc.constantFrom(...IDS_TABLES), { minLength: 1, maxLength: 4 }),
tours: fc.integer({ min: 1, max: 3 }),
participants: fc.uniqueArray(fc.constantFrom(...IDS_PARTICIPANTS), { minLength: 1, maxLength: 8 }),
})
.chain((tete) =>
fc
.record({
capacites: fc.array(fc.integer({ min: 2, max: 5 }), {
minLength: tete.tables.length,
maxLength: tete.tables.length,
}),
placement: fc.array(unTour(tete.participants, tete.tables.length), {
minLength: tete.tours,
maxLength: tete.tours,
}),
})
.map((suite) => ({ ...tete, ...suite })),
);
// La seconde variante d'une proposition retouche la première d'un geste :
// échanger deux places d'un tour, déplacer une personne vers une autre
// table ou la réserve, basculer l'attribution des sièges, changer la graine.
function retoucher(proposition, { geste, tour, de, vers }) {
const variante = copie(proposition);
const { sieges, reserve } = variante.placement[tour % variante.placement.length];
const places = sieges.flatMap((liste, table) => liste.map((_, rang) => [table, rang]));
if (geste === 'attribution') variante.siegesAttribues = !variante.siegesAttribues;
if (geste === 'graine') variante.graine = (variante.graine + 1) % 2 ** 32;
if (geste === 'echanger' && places.length > 0) {
const [t1, r1] = places[de % places.length];
const [t2, r2] = places[vers % places.length];
[sieges[t1][r1], sieges[t2][r2]] = [sieges[t2][r2], sieges[t1][r1]];
}
if (geste === 'deplacer' && places.length > 0) {
const [table, rang] = places[de % places.length];
const [personne] = sieges[table].splice(rang, 1);
const cible = vers % (sieges.length + 1);
(cible === sieges.length ? reserve : sieges[cible]).push(personne);
}
return variante;
}
const unePropositionEtSaRetouche = uneProposition.chain((proposition) =>
fc
.record({
geste: fc.constantFrom('echanger', 'deplacer', 'attribution', 'graine'),
tour: fc.nat(),
de: fc.nat(),
vers: fc.nat(),
})
.map((retouche) => [proposition, retoucher(proposition, retouche)]),
);
// Le vivier d'une paire : deux variantes de l'événement, des réglages, de
// chaque participant et de chaque table ; des réservations et des titres
// candidats ; trois propositions et leur retouche.
const unVivier = fc.record({
evenements: deuxFois(unEvenement),
reglages: deuxFois(desReglages),
participants: fc.tuple(...IDS_PARTICIPANTS.map((id) => deuxFois(unParticipant(id)))),
tables: fc.tuple(...IDS_TABLES.map((id) => deuxFois(uneTable(id)))),
reservations: fc.array(uneReservation, { minLength: 6, maxLength: 6 }),
titres: fc.array(unTitre, { minLength: 4, maxLength: 4 }),
propositions: fc.array(unePropositionEtSaRetouche, { minLength: 3, maxLength: 3 }),
});
// Le retenu tiré d'une proposition : ses champs de plan, sous son id.
const retenuDe = ({ id, siegesAttribues, tables, capacites, tours, participants, placement }) =>
copie({ proposition: id, siegesAttribues, tables, capacites, tours, participants, placement });
// Façons d'abîmer un retenu, chacune en place : sa règle le refuse, et
// l'analyse, qui n'en lit que le conteneur, l'admet ; examiner le garde en
// le signalant (§ 8.9, point 3).
const ABIMER_LE_RETENU = [
['« tours » renommé « toura »', (r) => {
r.toura = r.tours;
delete r.tours;
}],
['un placement objet', (r) => { r.placement = {}; }],
['des capacités nombre', (r) => { r.capacites = 2; }],
['un tour devenu la liste de ses tables', (r) => { r.placement[0] = r.placement[0].sieges; }],
['une clé inconnue', (r) => { r.note = 'à revoir'; }],
['tours à 0', (r) => { r.tours = 0; }],
];
// Vrai quand la charge porte un retenu que sa règle refuse.
const porteUnRetenuAbime = (charge) =>
charge.retenu !== null && fautes(charge.retenu).some(({ code }) => code === 'FORME');
// Charge tirée du vivier selon choisir(cle, rang) : 0 écarte un
// enregistrement, 1 et 2 en prennent la variante ; un booléen garde un
// candidat. Les réservations et les titres qui ne tiennent plus dans la
// charge s'écartent, comme la commande qui retire leur personne ou leur
// table : la charge reste un fichier que l'analyse admet. Une à trois
// propositions ; le retenu, null ou le plan d'une proposition, abîmé d'une
// des façons d'ABIMER_LE_RETENU quand abime en désigne une. Renversées, les
// collections arrivent hors de l'ordre canonique.
function construire(vivier, choisir) {
const evenement = copie(vivier.evenements[choisir('evenement')]);
const variantes = (cle) =>
vivier[cle].flatMap((deux, rang) => (choisir(cle, rang) === 0 ? [] : [copie(deux[choisir(cle, rang) - 1])]));
const participants = variantes('participants');
const tables = variantes('tables');
const capaciteDe = new Map(tables.map((t) => [t.id, t.sieges ?? evenement.siegesParDefaut]));
const presents = new Set(participants.map((p) => p.id));
const tient = (table, siege) => capaciteDe.has(table) && (siege === null || siege <= capaciteDe.get(table));
const reservations = vivier.reservations
.filter(
(r, rang) =>
choisir('reservations', rang) &&
presents.has(r.participant) &&
tient(r.table, r.siege) &&
(r.tour === null || r.tour <= evenement.tours),
)
.map(copie);
const titres = vivier.titres.filter((t, rang) => choisir('titres', rang) && tient(t.table, t.siege)).map(copie);
const propositions = variantes('propositions');
if (propositions.length === 0) propositions.push(copie(vivier.propositions[0][0]));
const retenu = choisir('retenu') < 3 ? null : retenuDe(vivier.propositions[choisir('retenu') - 3][0]);
if (retenu !== null && choisir('abime') !== null) ABIMER_LE_RETENU[choisir('abime')][1](retenu);
const charge = {
evenement,
reglages: copie(vivier.reglages[choisir('reglages')]),
prochainsIds: {
participant: IDS_PARTICIPANTS.length + 1 + choisir('prochains'),
table: IDS_TABLES.length + 1 + choisir('prochains'),
proposition: ID_PROPOSITION_MAX + 1 + choisir('prochains'),
},
participants,
tables,
reservations,
titres,
propositions,
retenu,
};
if (choisir('renverser')) {
for (const cle of ['participants', 'tables', 'reservations', 'titres', 'propositions']) charge[cle].reverse();
}
return charge;
}
// Les choix de longueur éléments : pour chacun, un tirage pour a, un pour
// b, et un dé de 0 à 3.
const desChoix = (element, longueur) =>
fc.array(fc.tuple(element, element, fc.integer({ min: 0, max: 3 })), { minLength: longueur, maxLength: longueur });
// Une paire de charges tirées d'un même vivier. b reprend le choix de a
// sauf quand le dé passe sous l'écart de la paire : l'écart 0 rend deux
// charges égales, 1 à 3 deux charges voisines, 4 deux charges aux choix
// indépendants.
const unePaire = fc
.record({
vivier: unVivier,
ecart: fc.integer({ min: 0, max: 4 }),
choix: fc.record({
evenement: desChoix(fc.integer({ min: 0, max: 1 }), 1),
reglages: desChoix(fc.integer({ min: 0, max: 1 }), 1),
prochains: desChoix(fc.integer({ min: 0, max: 2 }), 1),
participants: desChoix(fc.integer({ min: 0, max: 2 }), IDS_PARTICIPANTS.length),
tables: desChoix(fc.integer({ min: 0, max: 2 }), IDS_TABLES.length),
reservations: desChoix(fc.boolean(), 6),
titres: desChoix(fc.boolean(), 4),
propositions: desChoix(fc.integer({ min: 0, max: 2 }), 3),
retenu: desChoix(fc.integer({ min: 0, max: 5 }), 1),
renverser: desChoix(fc.boolean(), 1),
abime: desChoix(ouNul(fc.integer({ min: 0, max: ABIMER_LE_RETENU.length - 1 })), 1),
}),
})
.map(({ vivier, ecart, choix }) => ({
a: construire(vivier, (cle, rang = 0) => choix[cle][rang][0]),
b: construire(vivier, (cle, rang = 0) => {
const [pourA, pourB, de] = choix[cle][rang];
return de < ecart ? pourB : pourA;
}),
}));
// Vrai quand deux propositions de la charge partagent un identifiant.
const identifiantsRepetes = (charge) => new Set(charge.propositions.map((p) => p.id)).size < charge.propositions.length;
const derniere = ({ chemin }) => chemin[chemin.length - 1];
describe('difference et appliquer : aller-retour sur des paires tirées (§ 8.6)', () => {
test(`${TIRAGES} paires : appliquer(a, difference(a, b)) s'écrit comme b, et ni a ni b ne changent`, () => {
const bilan = {
tirages: 0,
vides: 0,
retraits: 0,
insertions: 0,
communsCompares: 0,
parRang: 0,
listesEntieres: 0,
identifiantsRepetes: 0,
propositionsParRang: 0,
retenuBascule: 0,
retenuAbime: 0,
retenuAbimePose: 0,
};
fc.assert(
fc.property(unePaire, ({ a, b }) => {
// Les deux charges tirées sont des fichiers que l'analyse admet.
for (const charge of [a, b]) assert.equal(analyser(serialiser(charge, ENTETE)).formatPlusRecent, false);
const [texteA, texteB] = [JSON.stringify(a), JSON.stringify(b)];
geler(a);
geler(b);
const correctif = difference(a, b);
const rendu = appliquer(a, correctif);
assert.equal(serialiserCharge(rendu), serialiserCharge(b));
// Le résultat est la copie canonique de b, ordre des clés compris.
assert.equal(JSON.stringify(rendu), JSON.stringify(canoniser(b)));
// Le journal porte le correctif en JSON : relu, il s'applique pareil.
assert.equal(serialiserCharge(appliquer(a, JSON.parse(JSON.stringify(correctif)))), serialiserCharge(b));
// Égales à l'ordre près, deux charges n'ont rien à corriger.
assert.deepEqual(difference(b, canoniser(b)), []);
assert.equal(JSON.stringify(a), texteA);
assert.equal(JSON.stringify(b), texteB);
bilan.tirages += 1;
if (correctif.length === 0) bilan.vides += 1;
if (correctif.some(({ op }) => op === 'retirer')) bilan.retraits += 1;
if (correctif.some((o) => o.op === 'poser' && typeof derniere(o) === 'object')) bilan.insertions += 1;
if (correctif.some(({ chemin }) => chemin.slice(0, -1).some((etape) => typeof etape === 'object'))) {
bilan.communsCompares += 1;
}
if (correctif.some(({ chemin }) => chemin.some((etape) => typeof etape === 'number'))) bilan.parRang += 1;
if (correctif.some((o) => o.op === 'poser' && Array.isArray(o.valeur))) bilan.listesEntieres += 1;
if (identifiantsRepetes(a) || identifiantsRepetes(b)) bilan.identifiantsRepetes += 1;
if (correctif.some(({ chemin }) => chemin[0] === 'propositions' && typeof chemin[1] === 'number')) {
bilan.propositionsParRang += 1;
}
if ((a.retenu === null) !== (b.retenu === null)) bilan.retenuBascule += 1;
if (porteUnRetenuAbime(a) || porteUnRetenuAbime(b)) {
bilan.retenuAbime += 1;
if (correctif.some(({ chemin }) => chemin.length === 1 && chemin[0] === 'retenu')) bilan.retenuAbimePose += 1;
}
}),
{ seed: GRAINE, numRuns: TIRAGES },
);
// Un générateur qui n'engendre pas les cas difficiles rend la propriété
// vraie sans rien éprouver : chaque famille de cas a son plancher. Les
// propositions comparées par rang, faute d'identifiants distincts de
// part et d'autre et à longueur égale, sont les plus rares.
assert.equal(bilan.tirages, TIRAGES);
const planchers = [
['vides', 30],
['retraits', 30],
['insertions', 30],
['communsCompares', 30],
['parRang', 30],
['listesEntieres', 30],
['identifiantsRepetes', 30],
['propositionsParRang', 10],
['retenuBascule', 30],
['retenuAbime', 30],
['retenuAbimePose', 10],
];
for (const [famille, plancher] of planchers) {
assert.ok(bilan[famille] >= plancher, `${famille} : ${bilan[famille]} tirages sur ${TIRAGES}`);
}
});
});
describe('difference : localité sur la grande démonstration (§ 8.9)', () => {
// Chemin des listes de table du tour 2 (rang 1) de la proposition id.
const listesDuTour2 = (id) => ['propositions', { id }, 'placement', 1, 'sieges'];
test("la charge d'épreuve se relit, et chaque tour de ses propositions place chaque participant une fois", () => {
const charge = grande();
assert.equal(analyser(serialiser(charge, ENTETE)).formatPlusRecent, false);
const ids = charge.participants.map((p) => p.id);
assert.equal(ids.length, 260);
for (const proposition of charge.propositions) {
for (const { sieges, reserve } of proposition.placement) {
assert.deepEqual([...sieges.flat(), ...reserve].sort(croissant), ids);
assert.deepEqual(
sieges.map((liste) => liste.length),
proposition.capacites,
);
}
}
});
test("au tour 2 d'une proposition à sièges attribués, déplacer une personne vers une autre table, pleine, l'échange avec l'occupant : deux opérations, une entrée de chacune des deux listes, moins de 200 octets", () => {
const avant = grande();
const apres = copie(avant);
const { sieges } = apres.propositions.find((p) => p.id === 7).placement[1];
const [personne, occupant] = [sieges[4][2], sieges[30][6]];
sieges[4][2] = occupant;
sieges[30][6] = personne;
const correctif = difference(avant, apres);
assert.deepEqual(correctif, [
{ op: 'poser', chemin: [...listesDuTour2(7), 4, 2], valeur: occupant },
{ op: 'poser', chemin: [...listesDuTour2(7), 30, 6], valeur: personne },
]);
const taille = octets(JSON.stringify(correctif));
assert.ok(taille < 200, `${taille} octets`);
assert.equal(serialiserCharge(appliquer(avant, correctif)), serialiserCharge(apres));
});
test("sans attribution des sièges, le même échange entre deux identifiants consécutifs laisse à chacun le rang de l'autre : deux opérations, moins de 200 octets", () => {
const avant = grande();
const apres = copie(avant);
const { sieges } = apres.propositions.find((p) => p.id === 4).placement[1];
const personne = sieges[4][2];
const voisin = personne + 1;
const tableDuVoisin = sieges.findIndex((liste) => liste.includes(voisin));
const rangDuVoisin = sieges[tableDuVoisin].indexOf(voisin);
assert.notEqual(tableDuVoisin, 4);
sieges[4][2] = voisin;
sieges[tableDuVoisin][rangDuVoisin] = personne;
const correctif = difference(avant, apres);
const attendu = [
{ op: 'poser', chemin: [...listesDuTour2(4), 4, 2], valeur: voisin },
{ op: 'poser', chemin: [...listesDuTour2(4), tableDuVoisin, rangDuVoisin], valeur: personne },
].sort((x, y) => x.chemin[5] - y.chemin[5]);
assert.deepEqual(correctif, attendu);
const taille = octets(JSON.stringify(correctif));
assert.ok(taille < 200, `${taille} octets`);
});
test("sans attribution, un échange entre deux tables pleines tient en deux opérations de moins de 250 octets, une par liste : une liste d'identifiants se pose entière quand ses opérations rang par rang pèsent plus qu'elle", () => {
const avant = grande();
const listes = avant.propositions.find((p) => p.id === 4).placement[1].sieges;
// Échange [t1, r1, t2, r2] : la personne du rang r1 de la table de rang
// t1 contre celle du rang r2 de la table de rang t2, puis chaque liste
// rangée par identifiant, comme la copie canonique d'une proposition
// sans attribution. Chacune entre dans l'autre liste à son rang, et les
// entrées qu'elle franchit se décalent d'un rang.
const echanger = ([t1, r1, t2, r2]) => {
const apres = copie(avant);
const { sieges } = apres.propositions.find((p) => p.id === 4).placement[1];
[sieges[t1][r1], sieges[t2][r2]] = [sieges[t2][r2], sieges[t1][r1]];
for (const t of [t1, t2]) sieges[t].sort(croissant);
return { apres, sieges };
};
// Les deux listes posées entières, dans l'ordre des rangs de table.
const entieres = (sieges, tables) =>
[...tables].sort(croissant).map((t) => ({ op: 'poser', chemin: [...listesDuTour2(4), t], valeur: sieges[t] }));
// La plus petite personne de la table de rang 4 contre la plus grande de
// celle de rang 30 : chacune franchit toute l'autre liste, et ses poses
// rang par rang pèseraient plus que les deux listes entières.
const extremes = [4, 0, 30, listes[30].length - 1];
const { apres, sieges } = echanger(extremes);
assert.deepEqual(difference(avant, apres), entieres(sieges, [4, 30]));
// Deux identifiants consécutifs, que l'échange laisse chacun au rang de
// l'autre : une pose par liste, au rang qui change. Puis, de chaque
// table, une personne contre une personne de la table onze rangs plus
// loin, à des rangs qui varient d'une table à l'autre.
const voisin = listes[4][2] + 1;
const tableDuVoisin = listes.findIndex((liste) => liste.includes(voisin));
const echanges = [
extremes,
[4, 2, tableDuVoisin, listes[tableDuVoisin].indexOf(voisin)],
...listes.map((liste, t) => {
const autre = (t + 11) % listes.length;
return [t, t % liste.length, autre, (3 * t + 1) % listes[autre].length];
}),
];
const formes = new Set();
for (const echange of echanges) {
const [t1, , t2] = echange;
const { apres: rendu, sieges: rangees } = echanger(echange);
const correctif = difference(avant, rendu);
const cas = JSON.stringify(echange);
assert.equal(correctif.length, 2, cas);
assert.deepEqual(correctif.map(({ chemin }) => chemin[5]), [t1, t2].sort(croissant), cas);
for (const { op, chemin } of correctif) {
assert.equal(op, 'poser', cas);
assert.deepEqual(chemin.slice(0, 5), listesDuTour2(4), cas);
formes.add(chemin.length);
}
const taille = octets(JSON.stringify(correctif));
assert.ok(taille < 250, `${cas} : ${taille} octets`);
assert.ok(taille <= octets(JSON.stringify(entieres(rangees, [t1, t2]))), `${cas} : ${taille} octets`);
assert.equal(serialiserCharge(appliquer(avant, correctif)), serialiserCharge(rendu), cas);
}
// Le balayage rencontre les deux issues : une liste posée entière, et une
// seule pose au rang qui change.
assert.deepEqual([...formes].sort(croissant), [6, 7]);
});
test('ajouter un participant donne une opération poser à { id }, et prochainsIds avance', () => {
const avant = grande();
const apres = copie(avant);
const id = avant.prochainsIds.participant;
const nouveau = {
id,
nom: 'Brindille',
prenom: 'Azur',
appartenance: 'Club Lanterne',
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
};
apres.participants.push(nouveau);
apres.prochainsIds.participant = id + 1;
assert.deepEqual(difference(avant, apres), [
{ op: 'poser', chemin: ['prochainsIds', 'participant'], valeur: id + 1 },
{ op: 'poser', chemin: ['participants', { id }], valeur: nouveau },
]);
});
test('retirer un participant donne retirer à { id }, plus ses réservations ; les propositions le gardent (§ 8.9, dérive)', () => {
const avant = grande();
const sans = (id) => {
const charge = copie(avant);
charge.participants = charge.participants.filter((p) => p.id !== id);
charge.reservations = charge.reservations.filter((r) => r.participant !== id);
return charge;
};
const libre = sansReservation(avant);
assert.deepEqual(difference(avant, sans(libre)), [{ op: 'retirer', chemin: ['participants', { id: libre }] }]);
// Un animateur emporte sa réservation : la liste, plus courte, est posée
// entière, dans l'ordre canonique, par personne.
const animateur = avant.reservations[0].participant;
const apres = sans(animateur);
assert.deepEqual(difference(avant, apres), [
{ op: 'retirer', chemin: ['participants', { id: animateur }] },
{
op: 'poser',
chemin: ['reservations'],
valeur: [...apres.reservations].sort((x, y) => x.participant - y.participant),
},
]);
});
test("revenir à l'instant d'avant un retrait remet la personne entre ses voisins : l'insertion par { id } garde l'ordre des identifiants", () => {
const avec = grande();
const id = sansReservation(avec, 100);
const sans = copie(avec);
sans.participants = sans.participants.filter((p) => p.id !== id);
const correctif = difference(sans, avec);
assert.deepEqual(correctif, [
{ op: 'poser', chemin: ['participants', { id }], valeur: avec.participants.find((p) => p.id === id) },
]);
const rendu = appliquer(sans, correctif);
assert.deepEqual(
rendu.participants.map((p) => p.id),
avec.participants.map((p) => p.id),
);
assert.equal(serialiserCharge(rendu), serialiserCharge(avec));
});
});
describe('difference : les règles (§ 8.6)', () => {
// Poses au rang qui mènent de la liste d'identifiants avant à la liste
// apres, de même longueur, au bout de chemin : une par rang qui change.
const posesAuRang = (avant, apres, chemin) =>
avant.flatMap((id, rang) =>
id === apres[rang] ? [] : [{ op: 'poser', chemin: [...chemin, rang], valeur: apres[rang] }],
);
// Poids d'opérations : les octets de leur texte JSON.
const poids = (operations) => octets(JSON.stringify(operations));
test("deux charges égales à l'ordre près donnent [] : ordre des clés, des collections, et des tables d'une proposition sans attribution", () => {
const a = petite();
const b = copie(a);
const { id, nom, date, siegesParDefaut, tours, unite, etat, filiation } = a.evenement;
b.evenement = { filiation, etat, unite, tours, siegesParDefaut, date, nom, id };
b.participants.reverse();
b.tables.reverse();
b.propositions[0].participants.reverse();
b.propositions[0].placement[1].sieges[0].reverse();
assert.notEqual(JSON.stringify(b), JSON.stringify(a));
assert.deepEqual(difference(a, b), []);
assert.deepEqual(difference(a, a), []);
});
test("les objets se comparent clé par clé dans l'ordre du schéma, quel que soit l'ordre des retouches ; un scalaire différent est posé", () => {
const a = petite();
const b = copie(a);
b.tables[2].position.x = 900;
b.reglages.generation.arret = 50_000;
b.reglages.attribuerSieges = true;
b.evenement.date = '2031-05-17';
b.evenement.nom = 'Petite soirée des Hérons';
assert.deepEqual(difference(a, b), [
{ op: 'poser', chemin: ['evenement', 'nom'], valeur: 'Petite soirée des Hérons' },
{ op: 'poser', chemin: ['evenement', 'date'], valeur: '2031-05-17' },
{ op: 'poser', chemin: ['reglages', 'attribuerSieges'], valeur: true },
{ op: 'poser', chemin: ['reglages', 'generation', 'arret'], valeur: 50_000 },
{ op: 'poser', chemin: ['tables', { id: 3 }, 'position', 'x'], valeur: 900 },
]);
});
test('les listes à identifiant se comparent par identifiant croissant : retirés, posés entiers, communs comparés', () => {
const a = chargeDe('petite', [
{ id: 1, siegesAttribues: false },
{ id: 4, siegesAttribues: false },
]);
const b = copie(a);
const nouvelle = { id: 5, numero: 5, sieges: 4, forme: 'carree', position: { x: 1000, y: 0 } };
b.tables = [nouvelle, ...b.tables.filter((t) => t.id !== 2)];
b.tables.find((t) => t.id === 3).numero = 7;
b.prochainsIds.table = 6;
const troisieme = { ...copie(a.propositions[0]), id: 3, graine: 11 };
b.propositions = [troisieme, { ...b.propositions[0], graine: 12 }];
assert.deepEqual(difference(a, b), [
{ op: 'poser', chemin: ['prochainsIds', 'table'], valeur: 6 },
{ op: 'retirer', chemin: ['tables', { id: 2 }] },
{ op: 'poser', chemin: ['tables', { id: 3 }, 'numero'], valeur: 7 },
{ op: 'poser', chemin: ['tables', { id: 5 }], valeur: nouvelle },
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'graine'], valeur: 12 },
{ op: 'poser', chemin: ['propositions', { id: 3 }], valeur: troisieme },
{ op: 'retirer', chemin: ['propositions', { id: 4 }] },
]);
const rendu = appliquer(a, difference(a, b));
assert.deepEqual(
rendu.tables.map((t) => t.id),
[1, 3, 4, 5],
);
assert.deepEqual(
rendu.propositions.map((p) => p.id),
[1, 3],
);
});
test("d'une charge neuve, le premier participant, la première table et la première proposition se posent par { id } ; le dernier participant et la dernière proposition se retirent par { id } ; chaque correctif rend sa cible", () => {
const c0 = creerCharge({ id: 'evt-sonde', nom: 'Soirée sonde', siegesParDefaut: 4, tours: 2 });
const personne = {
id: 1,
nom: 'Brindille',
prenom: 'Azur',
appartenance: null,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
};
const tableNeuve = { id: 1, numero: 1, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } };
const proposition = {
id: 1,
graine: 7,
arret: 20_000,
historique: 1_000,
produitVersion: ENTETE.produitVersion,
siegesAttribues: false,
tables: [1],
capacites: [4],
tours: 2,
participants: [1],
placement: [
{ sieges: [[1]], reserve: [] },
{ sieges: [[1]], reserve: [] },
],
};
const suivante = (charge, modifier) => {
const nouvelle = copie(charge);
modifier(nouvelle);
return nouvelle;
};
const c1 = suivante(c0, (c) => {
c.participants.push(personne);
c.prochainsIds.participant = 2;
});
const c2 = suivante(c1, (c) => {
c.tables.push(tableNeuve);
c.prochainsIds.table = 2;
});
const c3 = suivante(c2, (c) => {
c.propositions.push(proposition);
c.prochainsIds.proposition = 2;
});
const c4 = suivante(c3, (c) => {
c.participants = [];
});
const c5 = suivante(c4, (c) => {
c.propositions = [];
});
const etapes = [
[c0, c1, [
{ op: 'poser', chemin: ['prochainsIds', 'participant'], valeur: 2 },
{ op: 'poser', chemin: ['participants', { id: 1 }], valeur: personne },
]],
[c1, c2, [
{ op: 'poser', chemin: ['prochainsIds', 'table'], valeur: 2 },
{ op: 'poser', chemin: ['tables', { id: 1 }], valeur: tableNeuve },
]],
[c2, c3, [
{ op: 'poser', chemin: ['prochainsIds', 'proposition'], valeur: 2 },
{ op: 'poser', chemin: ['propositions', { id: 1 }], valeur: proposition },
]],
[c3, c4, [{ op: 'retirer', chemin: ['participants', { id: 1 }] }]],
[c4, c5, [{ op: 'retirer', chemin: ['propositions', { id: 1 }] }]],
];
for (const [avant, apres, attendu] of etapes) {
// Chaque charge est un fichier que l'analyse admet.
assert.equal(analyser(serialiser(apres, ENTETE)).formatPlusRecent, false);
assert.deepEqual(difference(avant, apres), attendu);
assert.equal(serialiserCharge(appliquer(avant, attendu)), serialiserCharge(apres));
}
});
test("les autres listes : de même longueur, rang par rang, des capacités toutes changées comprises ; de longueurs différentes, posées entières", () => {
const a = petite();
a.reservations = [
{ participant: 2, table: 1, siege: 1, portee: 'tous', tour: null },
{ participant: 5, table: 2, siege: null, portee: 'tour', tour: 3 },
];
a.titres = [{ table: 1, siege: 1, libelle: 'animation' }];
const b = copie(a);
b.reservations[1].siege = 2;
b.titres.push({ table: 2, siege: 3, libelle: 'accueil' });
// Une capacité ne change que son rang : la pose d'une liste entière plus
// légère que ses poses rang par rang ne vaut que pour une liste
// d'identifiants, que l'ordre canonique décale.
b.propositions[0].capacites = [4, 4, 4, 4];
// Au tour 1, la première personne de la table de rang 0 passe à la réserve.
const tour1 = b.propositions[0].placement[0];
const [personne, ...restants] = tour1.sieges[0];
tour1.sieges[0] = restants;
tour1.reserve = [personne];
assert.deepEqual(difference(a, b), [
{ op: 'poser', chemin: ['reservations', 1, 'siege'], valeur: 2 },
{
op: 'poser',
chemin: ['titres'],
valeur: [
{ table: 1, siege: 1, libelle: 'animation' },
{ table: 2, siege: 3, libelle: 'accueil' },
],
},
...[0, 1, 2, 3].map((rang) => ({
op: 'poser',
chemin: ['propositions', { id: 1 }, 'capacites', rang],
valeur: 4,
})),
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0, 'sieges', 0], valeur: restants },
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0, 'reserve'], valeur: [personne] },
]);
});
test('null contre une valeur : la valeur posée entière, et null posé au retour ; deux valeurs non nulles se comparent', () => {
const a = petite();
const b = copie(a);
b.retenu = retenuDe(a.propositions[0]);
b.evenement.filiation = {
source: { id: 'evt-source', nom: 'Soirée source' },
instant: { revision: 12, libelle: 'Ajouté Azur Brindille' },
};
assert.deepEqual(difference(a, b), [
{ op: 'poser', chemin: ['evenement', 'filiation'], valeur: b.evenement.filiation },
{ op: 'poser', chemin: ['retenu'], valeur: b.retenu },
]);
assert.deepEqual(difference(b, a), [
{ op: 'poser', chemin: ['evenement', 'filiation'], valeur: null },
{ op: 'poser', chemin: ['retenu'], valeur: null },
]);
const c = copie(b);
c.retenu.capacites[2] = 4;
c.evenement.filiation.instant.revision = 13;
assert.deepEqual(difference(b, c), [
{ op: 'poser', chemin: ['evenement', 'filiation', 'instant', 'revision'], valeur: 13 },
{ op: 'poser', chemin: ['retenu', 'capacites', 2], valeur: 4 },
]);
});
test("un retenu hors de sa règle, que l'analyse admet et qu'examiner garde : le fichier relu s'écrit et se rend par appliquer(·, []) ; difference le pose entier quand il change — libéré, remis, remplacé, abîmé autrement — et ne produit rien pour lui quand seule la saisie change (§ 8.9, point 3)", () => {
// Le fichier de la petite démonstration, son retenu abîmé à la main,
// relu puis examiné : la charge qu'ouvre le dépôt.
const base = petite();
base.retenu = retenuDe(base.propositions[0]);
base.evenement.etat = 'retenu';
const ouvrir = (abimer) => {
const ecrit = JSON.parse(serialiser(base, ENTETE));
abimer(ecrit.charge.retenu);
return examiner(analyser(JSON.stringify(ecrit)).charge);
};
const pose = (valeur) => [{ op: 'poser', chemin: ['retenu'], valeur }];
// Le correctif de a vers b est attendu, et il rend b, relu par JSON.
const verifier = (a, b, attendu) => {
const correctif = difference(a, b);
assert.deepEqual(correctif, attendu);
assert.equal(serialiserCharge(appliquer(a, JSON.parse(JSON.stringify(correctif)))), serialiserCharge(b));
};
assert.ok(ABIMER_LE_RETENU.length > 1, 'moins de deux façons examinées');
const ecarts = ABIMER_LE_RETENU.flatMap(([libelle, abimer], rang) => {
try {
const examen = ouvrir(abimer);
assert.equal(examen.retenu.fautes[0].code, 'FORME');
const ouverte = examen.charge;
const texte = serialiser(ouverte, ENTETE);
assert.deepEqual(analyser(texte).charge.retenu, ouverte.retenu);
assert.equal(serialiserCharge(appliquer(ouverte, [])), serialiserCharge(ouverte));
const liberee = { ...ouverte, retenu: null };
verifier(ouverte, liberee, pose(null));
verifier(liberee, ouverte, pose(canoniser(ouverte).retenu));
const renommee = copie(ouverte);
renommee.participants[0].nom = 'Brindille';
verifier(ouverte, renommee, [{ op: 'poser', chemin: ['participants', { id: 1 }, 'nom'], valeur: 'Brindille' }]);
verifier(ouverte, base, pose(canoniser(base).retenu));
verifier(base, ouverte, pose(canoniser(ouverte).retenu));
const autre = ouvrir(ABIMER_LE_RETENU[(rang + 1) % ABIMER_LE_RETENU.length][1]).charge;
verifier(ouverte, autre, pose(canoniser(autre).retenu));
return [];
} catch (erreur) {
return [`${libelle} : ${erreur.name} ${erreur.message.split('\n')[0]}`];
}
});
assert.deepEqual(ecarts, []);
});
test("un retenu que sa règle admet mais qui se contredit — cinq tours déclarés, quatre écrits — se compare champ par champ, comme un retenu cohérent : seul un retenu hors de sa règle se pose entier (§ 8.9, point 3)", () => {
const a = petite();
a.retenu = { ...retenuDe(a.propositions[0]), tours: 5 };
a.evenement.etat = 'retenu';
// Relu, le retenu est gardé, et sa contradiction nommée.
const examen = examiner(analyser(serialiser(a, ENTETE)).charge);
assert.deepEqual(examen.retenu.fautes, [{ code: 'LONGUEUR', tour: null, table: null, declare: 5, ecrit: 4 }]);
assert.equal(porteUnRetenuAbime(a), false);
// Basculer le drapeau garde l'ordre des listes, rangées sans
// attribution ; la première liste du premier tour s'inverse.
const b = copie(a);
b.retenu.siegesAttribues = true;
b.retenu.placement[0].sieges[0].reverse();
assert.deepEqual(difference(a, b), [
{ op: 'poser', chemin: ['retenu', 'siegesAttribues'], valeur: true },
{ op: 'poser', chemin: ['retenu', 'placement', 0, 'sieges', 0], valeur: b.retenu.placement[0].sieges[0] },
]);
assert.equal(serialiserCharge(appliquer(a, difference(a, b))), serialiserCharge(b));
});
test("deux propositions de même identifiant : la liste se compare par rang dans l'ordre canonique, ou se pose entière", () => {
const a = petite();
a.propositions.push({ ...copie(a.propositions[0]), graine: 7 });
const b = copie(a);
b.propositions.find((p) => p.graine !== 7).arret = 50_000;
// L'ordre canonique range la graine 7 avant l'autre, au rang 0.
assert.deepEqual(difference(a, b), [{ op: 'poser', chemin: ['propositions', 1, 'arret'], valeur: 50_000 }]);
assert.equal(serialiserCharge(appliquer(a, difference(a, b))), serialiserCharge(b));
const c = copie(a);
c.propositions = c.propositions.filter((p) => p.graine === 7);
assert.deepEqual(difference(a, c), [{ op: 'poser', chemin: ['propositions'], valeur: c.propositions }]);
assert.equal(serialiserCharge(appliquer(a, difference(a, c))), serialiserCharge(c));
});
test("le drapeau de la proposition décide : sans attribution, l'ordre dans une table ne porte rien ; avec, il est celui des sièges", () => {
const a = chargeDe('petite', [
{ id: 1, siegesAttribues: false },
{ id: 2, siegesAttribues: true },
]);
const b = copie(a);
for (const proposition of b.propositions) proposition.placement[0].sieges[0].reverse();
// Le premier et le dernier des trois sièges changent : leurs deux poses
// pèsent plus que la liste posée entière.
assert.deepEqual(difference(a, b), [
{
op: 'poser',
chemin: ['propositions', { id: 2 }, 'placement', 0, 'sieges', 0],
valeur: [...a.propositions[1].placement[0].sieges[0]].reverse(),
},
]);
assert.equal(serialiserCharge(appliquer(a, difference(a, b))), serialiserCharge(b));
});
test("à poids égal, la pose au rang reste : la seule personne de la réserve échangée contre un occupant donne une pose au rang de chaque liste", () => {
// Au tour 1 d'une proposition à sièges attribués, la troisième personne
// de la première table attend à la réserve, puis prend le siège du
// milieu de la deuxième table, dont l'occupant passe à la réserve.
const a = chargeDe('petite', [{ id: 1, siegesAttribues: true }]);
const tour1 = a.propositions[0].placement[0];
tour1.reserve = [tour1.sieges[0].pop()];
const b = copie(a);
const { sieges, reserve } = b.propositions[0].placement[0];
[sieges[1][1], reserve[0]] = [reserve[0], sieges[1][1]];
const chemin = ['propositions', { id: 1 }, 'placement', 0];
const correctif = difference(a, b);
assert.deepEqual(correctif, [
{ op: 'poser', chemin: [...chemin, 'sieges', 1, 1], valeur: sieges[1][1] },
{ op: 'poser', chemin: [...chemin, 'reserve', 0], valeur: reserve[0] },
]);
// La réserve posée entière pèserait autant que sa pose au rang.
const entiere = { op: 'poser', chemin: [...chemin, 'reserve'], valeur: reserve };
assert.equal(octets(JSON.stringify([entiere])), octets(JSON.stringify([correctif[1]])));
assert.equal(serialiserCharge(appliquer(a, correctif)), serialiserCharge(b));
});
test("une liste d'identifiants se pose entière selon le poids de ses poses au rang, et non selon leur nombre : deux rangs changés gardent leurs deux poses dans les 260 participants d'une proposition, et cèdent la place à la liste entière dans une liste de table de huit", () => {
const a = grande();
const chemin = ['propositions', { id: 4 }];
// Dans les participants de la proposition 4, 259 cède la place à 261 :
// la liste rangée change à ses deux derniers rangs, et leurs deux poses
// pèsent bien moins que la liste entière.
const b = copie(a);
const declaree = b.propositions.find((p) => p.id === 4);
assert.deepEqual(declaree.participants.slice(-2), [259, 260]);
declaree.participants = [...declaree.participants.slice(0, -2), 260, 261];
const cheminDesParticipants = [...chemin, 'participants'];
const deuxPoses = [
{ op: 'poser', chemin: [...cheminDesParticipants, 258], valeur: 260 },
{ op: 'poser', chemin: [...cheminDesParticipants, 259], valeur: 261 },
];
assert.deepEqual(difference(a, b), deuxPoses);
const listeEntiere = { op: 'poser', chemin: cheminDesParticipants, valeur: declaree.participants };
assert.ok(poids(deuxPoses) < poids([listeEntiere]), `${poids(deuxPoses)} octets au rang`);
assert.equal(serialiserCharge(appliquer(a, deuxPoses)), serialiserCharge(b));
// Au tour 2, la quatrième personne de la table de rang 0 contre la
// cinquième de celle de rang 1 : chaque liste de huit, rangée, change à
// deux rangs, et ses deux poses pèsent plus qu'elle posée entière.
const c = copie(a);
const avant = a.propositions.find((p) => p.id === 4).placement[1].sieges;
const { sieges } = c.propositions.find((p) => p.id === 4).placement[1];
[sieges[0][3], sieges[1][4]] = [sieges[1][4], sieges[0][3]];
for (const t of [0, 1]) sieges[t].sort(croissant);
const cheminDesListes = [...chemin, 'placement', 1, 'sieges'];
const entieres = [0, 1].map((t) => ({ op: 'poser', chemin: [...cheminDesListes, t], valeur: sieges[t] }));
for (const [t, entiere] of entieres.entries()) {
const auRang = posesAuRang(avant[t], sieges[t], entiere.chemin);
assert.equal(auRang.length, 2, `table de rang ${t}`);
assert.ok(poids(auRang) > poids([entiere]), `table de rang ${t} : ${poids(auRang)} octets au rang`);
}
assert.deepEqual(difference(a, c), entieres);
assert.equal(serialiserCharge(appliquer(a, entieres)), serialiserCharge(c));
});
test("la pose entière vaut pour chaque sorte de liste d'identifiants, d'une proposition comme du retenu — ses tables, ses participants, la réserve d'un tour — : une liste qui change à chaque rang donne une seule pose, la liste entière", () => {
// La grande démonstration : au tour 2 de la proposition 4, trois
// personnes de la table de rang 0 attendent à la réserve ; le retenu
// vient de cette proposition.
const a = grande();
const proposition = a.propositions.find((p) => p.id === 4);
const tour2 = proposition.placement[1];
tour2.reserve = tour2.sieges[0].splice(1, 3);
a.retenu = retenuDe(proposition);
a.evenement.etat = 'retenu';
// Chaque liste change dans une copie de a, à chaque rang : les tables
// tournent d'un rang ; dans un ensemble rangé, le plus petit identifiant
// cède la place à 261, plus grand que tous.
const tourner = (liste) => [...liste.slice(1), liste[0]];
const remplacerLePremier = (liste) => [...liste.slice(1), 261];
const listes = [
[['tables'], tourner],
[['participants'], remplacerLePremier],
[['placement', 1, 'reserve'], remplacerLePremier],
];
const porteurs = [
[['propositions', { id: 4 }], (charge) => charge.propositions.find((p) => p.id === 4)],
[['retenu'], (charge) => charge.retenu],
];
const ecarts = porteurs.flatMap(([cheminDuPorteur, porteurDe]) =>
listes.flatMap(([cles, changer]) => {
const b = copie(a);
const parent = cles.slice(0, -1).reduce((valeur, cle) => valeur[cle], porteurDe(b));
const cle = cles.at(-1);
const avant = parent[cle];
parent[cle] = changer(avant);
const chemin = [...cheminDuPorteur, ...cles];
const attendu = [{ op: 'poser', chemin, valeur: parent[cle] }];
const auRang = posesAuRang(avant, parent[cle], chemin);
const correctif = difference(a, b);
const cas = JSON.stringify(chemin);
// Chaque rang change : rang par rang, le correctif compterait
// plusieurs poses.
if (auRang.length < 2 || auRang.length !== avant.length) return [`${cas} : ${auRang.length} rangs changés`];
if (JSON.stringify(correctif) !== JSON.stringify(attendu)) {
return [`${cas} : ${correctif.length} opérations, ${poids(correctif)} octets`];
}
return serialiserCharge(appliquer(a, correctif)) === serialiserCharge(b) ? [] : [`${cas} : cible non rendue`];
}),
);
assert.deepEqual(ecarts, []);
});
});
describe('appliquer : une copie canonique, sans partage (§ 8.6)', () => {
test('ni a ni le correctif ne changent, et le résultat ne partage aucun objet avec eux', () => {
const a = geler(grande());
const b = copie(a);
// Une opération de chaque sorte : retrait et insertion par { id }, objet
// et liste posés entiers, scalaires posés.
b.participants.shift();
b.tables.push({ id: b.prochainsIds.table, numero: 34, sieges: null, forme: 'carree', position: { x: 0, y: 1500 } });
b.prochainsIds.table += 1;
b.tables[0].position.y = 75;
// Deux personnes échangent leurs sièges attribués : une pose au rang de
// chacune des deux listes.
const { sieges: echangees } = b.propositions.find((p) => p.id === 7).placement[3];
[echangees[0][1], echangees[1][2]] = [echangees[1][2], echangees[0][1]];
const tour1 = b.propositions.find((p) => p.id === 9).placement[0];
tour1.reserve = [tour1.sieges[0].pop()];
b.retenu = retenuDe(b.propositions[0]);
const correctif = geler(difference(a, b));
// Sorte d'une opération : son op, la sorte de sa dernière étape, celle
// de sa valeur.
const sorte = (operation) => `${operation.op} ${typeof operation.chemin.at(-1)} ${typeof operation.valeur}`;
assert.deepEqual([...new Set(correctif.map(sorte))].sort(), [
'poser number number',
'poser number object',
'poser object object',
'poser string number',
'poser string object',
'retirer object undefined',
]);
const rendu = appliquer(a, correctif);
assert.equal(serialiserCharge(rendu), serialiserCharge(b));
const ailleurs = new Set([...objetsDe(a), ...objetsDe(correctif)]);
const partages = [...objetsDe(rendu)].filter((objet) => ailleurs.has(objet));
assert.equal(partages.length, 0);
});
test("un retenu hors de sa règle, recopié hors du schéma, ne se partage pas davantage : le résultat ne partage rien avec a ni avec le correctif, et le correctif rien avec sa cible", () => {
// La petite démonstration, son retenu hors de sa règle : « tours »
// renommé « toura », une clé inconnue. Ses listes d'entiers se recopient
// hors du schéma.
const a = petite();
const { tours, ...plan } = retenuDe(a.propositions[0]);
a.retenu = { ...plan, toura: tours, note: 'à revoir' };
a.evenement.etat = 'retenu';
geler(a);
assert.ok(porteUnRetenuAbime(a));
// Un nom changé : le retenu du résultat vient de la copie de a. Une
// capacité du retenu changée : le correctif pose le retenu entier.
const renommee = copie(a);
renommee.participants[0].nom = 'Brindille';
const retouchee = copie(a);
retouchee.retenu.capacites[0] = 4;
assert.deepEqual(difference(a, retouchee), [{ op: 'poser', chemin: ['retenu'], valeur: retouchee.retenu }]);
for (const cible of [renommee, retouchee]) {
const correctif = geler(difference(a, cible));
const rendu = appliquer(a, correctif);
assert.equal(serialiserCharge(rendu), serialiserCharge(cible));
const ailleurs = new Set([...objetsDe(a), ...objetsDe(correctif)]);
assert.deepEqual([...objetsDe(rendu)].filter((objet) => ailleurs.has(objet)), []);
const deLaCible = objetsDe(cible);
assert.deepEqual([...objetsDe(correctif)].filter((objet) => deLaCible.has(objet)), []);
}
});
test('un correctif vide rend la copie canonique de a, et non a', () => {
const a = petite();
a.participants.reverse();
a.propositions[0].placement[0].sieges[0].reverse();
const rendu = appliquer(a, []);
assert.notEqual(rendu, a);
assert.equal(JSON.stringify(rendu), JSON.stringify(canoniser(a)));
});
});
describe("appliquer : un correctif qui ne s'applique pas lève CORRECTIF avec le rang de l'opération (§ 8.6)", () => {
test("appliqué à une autre charge que la sienne, le correctif est refusé à la première opération qui n'y trouve pas sa place", () => {
const a = grande();
const b = copie(a);
const absent = sansReservation(a);
b.evenement.nom = 'Grande soirée des Hérons';
b.reglages.attribuerSieges = true;
b.participants = b.participants.filter((p) => p.id !== absent);
const correctif = difference(a, b);
assert.deepEqual(
correctif.map(({ op }) => op),
['poser', 'poser', 'retirer'],
);
// Rejoué sur la charge qu'il a produite : les deux poses trouvent leur
// place, le retrait ne trouve plus la personne.
assert.deepEqual(refusDe(geler(b), correctif), { rang: 2 });
// Sur la petite démonstration, la proposition 7 n'existe pas.
const echange = copie(a);
const { sieges } = echange.propositions.find((p) => p.id === 7).placement[1];
[sieges[0][0], sieges[1][0]] = [sieges[1][0], sieges[0][0]];
assert.deepEqual(refusDe(geler(petite()), difference(a, echange)), { rang: 0 });
});
test('chaque opération qui ne désigne pas une place de la charge est refusée à son rang, et la charge reste intacte', () => {
const base = petite();
const troisieme = copie(base.participants[2]);
// La petite démonstration, et la même avec deux propositions d'id 1.
const jumelles = petite();
jumelles.propositions.push({ ...copie(jumelles.propositions[0]), graine: 7 });
// La même avec un retenu dans sa règle ; puis avec un retenu hors de sa
// règle, dont une clé inconnue porte une liste d'objets à identifiant.
const avecRetenu = petite();
avecRetenu.retenu = retenuDe(avecRetenu.propositions[0]);
const retenuAbime = petite();
retenuAbime.retenu = { ...retenuDe(retenuAbime.propositions[0]), note: [{ id: 1 }] };
// Chaque cas suit, au rang 0, une pose qu'appliquer admet : POSE, ou
// celle que le cas nomme après son opération.
const POSE = { op: 'poser', chemin: ['evenement', 'nom'], valeur: 'Soirée renommée' };
const cas = [
['retirer un identifiant absent', base, { op: 'retirer', chemin: ['participants', { id: 99 }] }],
[
'poser par { id } un identifiant présent',
base,
{ op: 'poser', chemin: ['participants', { id: 3 }], valeur: troisieme },
],
[
"poser par { id } l'enregistrement d'un autre identifiant",
base,
{ op: 'poser', chemin: ['participants', { id: 13 }], valeur: { ...troisieme, id: 14 } },
],
[
"poser par { id } autre chose qu'un enregistrement",
base,
{ op: 'poser', chemin: ['participants', { id: 13 }], valeur: 13 },
],
['poser par { id } la valeur null', base, { op: 'poser', chemin: ['participants', { id: 13 }], valeur: null }],
[
'poser par un { id } qui est un texte',
base,
{ op: 'poser', chemin: ['participants', { id: '13' }], valeur: { ...troisieme, id: '13' } },
],
[
"poser par { id } dans une liste d'entiers",
base,
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'participants', { id: 13 }], valeur: { id: 13 } },
],
[
"poser par { id } dans une liste d'objets sans identifiant",
base,
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', { id: 1 }], valeur: { id: 1 } },
],
[
'poser par { id } dans une réserve vide',
base,
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0, 'reserve', { id: 3 }], valeur: { id: 3 } },
],
[
'poser par { id } dans des réservations vides',
base,
{ op: 'poser', chemin: ['reservations', { id: 1 }], valeur: { id: 1 } },
],
[
"poser au-delà de la fin d'une liste",
base,
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0, 'sieges', 0, 3], valeur: 4 },
],
['poser dans une valeur nulle', base, { op: 'poser', chemin: ['retenu', 'tours'], valeur: 2 }],
[
'traverser une valeur nulle',
base,
{ op: 'poser', chemin: ['evenement', 'filiation', 'source', 'id'], valeur: 'evt-source' },
],
['poser à une clé absente', base, { op: 'poser', chemin: ['evenement', 'lieu'], valeur: 'Salle des fêtes' }],
['descendre par une clé absente', base, { op: 'poser', chemin: ['evenement', 'lieu', 'nom'], valeur: 'x' }],
// Une clé héritée du prototype commun des objets n'est pas une clé de
// la charge : l'admettre écrirait dans ce prototype, pour tout le
// processus.
['descendre par __proto__', base, { op: 'poser', chemin: ['evenement', '__proto__', 'toString'], valeur: 1 }],
['poser à une clé héritée', base, { op: 'poser', chemin: ['evenement', 'constructor'], valeur: 1 }],
['poser à __proto__ de la charge', base, { op: 'poser', chemin: ['__proto__'], valeur: {} }],
['un rang dans un objet', base, { op: 'poser', chemin: ['evenement', 0], valeur: 'x' }],
['un rang dans une chaîne', base, { op: 'poser', chemin: ['evenement', 'nom', 0], valeur: 'x' }],
['une clé dans une liste', base, { op: 'poser', chemin: ['participants', 'length'], valeur: 0 }],
['une clé dans une chaîne', base, { op: 'poser', chemin: ['evenement', 'nom', 'length'], valeur: 1 }],
['un { id } dans un objet', base, { op: 'poser', chemin: ['evenement', { id: 1 }], valeur: { id: 1 } }],
[
'descendre par un { id } dans un objet',
base,
{ op: 'poser', chemin: ['evenement', { id: 1 }, 'nom'], valeur: 'x' },
],
[
"un { id } dans une liste d'objets sans identifiant",
base,
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', { id: 1 }, 'reserve'], valeur: [] },
],
[
"un { id } dans une liste d'entiers",
base,
{ op: 'retirer', chemin: ['propositions', { id: 1 }, 'participants', { id: 3 }] },
],
[
'un { id } que portent deux enregistrements',
jumelles,
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'arret'], valeur: 5 },
],
[
'retirer un { id } que portent deux enregistrements',
jumelles,
{ op: 'retirer', chemin: ['propositions', { id: 1 }] },
],
['retirer une clé', base, { op: 'retirer', chemin: ['evenement', 'date'] }],
['retirer un rang', base, { op: 'retirer', chemin: ['participants', 0] }],
['une opération inconnue', base, { op: 'deplacer', chemin: ['evenement', 'nom'], valeur: 'x' }],
['un chemin vide', base, { op: 'poser', chemin: [], valeur: {} }],
['un chemin qui est un texte', base, { op: 'poser', chemin: 'evenement.nom', valeur: 'x' }],
['un chemin qui est un objet', base, { op: 'poser', chemin: { evenement: 'nom' }, valeur: 'x' }],
['poser sans valeur', base, { op: 'poser', chemin: ['evenement', 'nom'] }],
['une opération nulle', base, null],
['une opération qui est un texte', base, 'poser'],
['une opération qui est une liste', base, ['poser', ['evenement', 'nom'], 'x']],
['un rang négatif', base, { op: 'poser', chemin: ['participants', -1], valeur: troisieme }],
['un rang non entier', base, { op: 'poser', chemin: ['participants', 0.5], valeur: troisieme }],
['descendre par un rang négatif', base, { op: 'poser', chemin: ['participants', -1, 'nom'], valeur: 'x' }],
['une étape booléenne', base, { op: 'poser', chemin: ['participants', true], valeur: troisieme }],
['une étape nulle', base, { op: 'poser', chemin: [null], valeur: 'x' }],
['une étape qui est une liste', base, { op: 'retirer', chemin: ['participants', [3]] }],
['un { id } qui est un texte', base, { op: 'retirer', chemin: ['participants', { id: '3' }] }],
['une étape objet sans id', base, { op: 'retirer', chemin: ['participants', {}] }],
// Une valeur posée suit la règle de sa place, sa forme entière ; à la
// place du retenu, son seul conteneur. Un cas par genre de règle.
["poser un nombre à la place d'un objet", base, { op: 'poser', chemin: ['evenement'], valeur: 5 }],
['poser un objet sans les clés de sa règle', base, { op: 'poser', chemin: ['evenement'], valeur: {} }],
[
'poser un objet à clé inconnue',
base,
{ op: 'poser', chemin: ['reglages', 'generation'], valeur: { nombre: 5, arret: 200_000, historique: 1_000, x: 1 } },
],
["poser un objet à la place d'une liste", base, { op: 'poser', chemin: ['tables'], valeur: {} }],
['poser une chaîne vide', base, { op: 'poser', chemin: ['evenement', 'nom'], valeur: '' }],
[
"poser un nombre à la place d'une chaîne",
base,
{ op: 'poser', chemin: ['participants', { id: 3 }, 'prenom'], valeur: 5 },
],
['poser un entier sous son minimum', base, { op: 'poser', chemin: ['evenement', 'siegesParDefaut'], valeur: 1 }],
["poser un entier qui n'en est pas un", base, { op: 'poser', chemin: ['evenement', 'tours'], valeur: 1.5 }],
[
'poser un entier au-delà des entiers exacts',
base,
{ op: 'poser', chemin: ['prochainsIds', 'participant'], valeur: 2 ** 53 },
],
['poser un nombre infini', base, { op: 'poser', chemin: ['tables', { id: 2 }, 'position', 'x'], valeur: Infinity }],
["poser un texte à la place d'un nombre", base, { op: 'poser', chemin: ['tables', { id: 2 }, 'position', 'y'], valeur: '0' }],
["poser un texte à la place d'un booléen", base, { op: 'poser', chemin: ['reglages', 'attribuerSieges'], valeur: 'non' }],
['poser une valeur hors de celles que la règle énumère', base, { op: 'poser', chemin: ['evenement', 'etat'], valeur: 'archive' }],
['poser une date hors du calendrier', base, { op: 'poser', chemin: ['evenement', 'date'], valeur: '2026-02-30' }],
["poser null là où la règle ne l'admet pas", base, { op: 'poser', chemin: ['evenement', 'nom'], valeur: null }],
[
'poser par { id } un participant de forme fausse',
base,
{ op: 'poser', chemin: ['participants', { id: 13 }], valeur: { ...troisieme, id: 13, nom: '' } },
],
// Un identifiant reste sous 2^31. Rien ne compare ici l'enregistrement
// posé au compteur, qui borne déjà l'identifiant à l'analyse : seule sa
// règle le refuse.
[
"poser par { id } un participant d'identifiant 2^31",
base,
{ op: 'poser', chemin: ['participants', { id: 2 ** 31 }], valeur: { ...troisieme, id: 2 ** 31 } },
],
[
"poser par { id } une table d'identifiant 2^31",
base,
{ op: 'poser', chemin: ['tables', { id: 2 ** 31 }], valeur: { ...copie(base.tables[2]), id: 2 ** 31 } },
],
['poser un objet à la place des propositions', base, { op: 'poser', chemin: ['propositions'], valeur: {} }],
['poser un nombre à la place du retenu', base, { op: 'poser', chemin: ['retenu'], valeur: 5 }],
['poser une liste à la place du retenu', base, { op: 'poser', chemin: ['retenu'], valeur: [] }],
// Les propositions et le retenu, l'analyse ne les lit que comme
// conteneurs ; une valeur posée en eux suit pourtant la règle de sa
// place : une charge rendue n'a jamais une proposition que canoniser,
// difference ou serialiser ne sauraient lire.
[
"poser des propositions qui sont une liste d'entiers",
base,
{ op: 'poser', chemin: ['propositions'], valeur: [12] },
],
[
'poser par { id } une proposition sans les clés de sa règle',
base,
{ op: 'poser', chemin: ['propositions', { id: 9 }], valeur: { id: 9 } },
],
[
'poser par { id } la valeur null dans les propositions',
base,
{ op: 'poser', chemin: ['propositions', { id: 13 }], valeur: null },
],
[
'poser un placement qui est un objet',
base,
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'placement'], valeur: {} },
],
[
'poser un placement dont un tour porte un identifiant',
base,
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'placement'], valeur: [{ id: 1, sieges: [], reserve: [] }] },
],
[
'poser un tour à clé hors du schéma',
base,
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0], valeur: { sieges: [], reserve: [], x: [] } },
],
[
"poser un occupant qui n'est pas un identifiant",
base,
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0, 'sieges', 0, 0], valeur: 'Brindille' },
],
[
'poser un occupant au-delà de 2^31 − 1',
base,
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0, 'sieges', 0, 0], valeur: 2 ** 31 },
],
[
'poser sous le retenu un placement objet',
avecRetenu,
{ op: 'poser', chemin: ['retenu', 'placement'], valeur: {} },
],
[
"poser sous le retenu une table qui n'est pas un identifiant",
avecRetenu,
{ op: 'poser', chemin: ['retenu', 'tables', 0], valeur: 'x' },
],
// Un retenu hors de sa règle se pose entier, jamais par morceaux :
// difference ne descend pas en lui.
[
'poser sous un retenu hors de sa règle, même une valeur que sa place admet',
retenuAbime,
{ op: 'poser', chemin: ['retenu', 'capacites', 0], valeur: 3 },
],
// Une étape { id } ne vaut que dans une liste d'enregistrements du
// schéma : une clé hors du schéma n'en mène à aucune, même quand elle
// porte des objets à identifiant.
[
"retirer par { id } dans une liste qu'un retenu hors de sa règle porte sous une clé hors du schéma",
retenuAbime,
{ op: 'retirer', chemin: ['retenu', 'note', { id: 1 }] },
],
];
const texteBase = JSON.stringify(base);
geler(base);
geler(jumelles);
geler(avecRetenu);
geler(retenuAbime);
const ecarts = cas.flatMap(([libelle, charge, operation, prealable = POSE]) => {
const refus = refusDe(charge, geler([prealable, operation]));
return JSON.stringify(refus) === JSON.stringify({ rang: 1 }) ? [] : [`${libelle} : ${JSON.stringify(refus)}`];
});
assert.deepEqual(ecarts, []);
assert.equal(JSON.stringify(base), texteBase);
});
test("une insertion par { id } ne vaut que dans les participants, les tables et les propositions : toute autre liste de la charge la refuse, vide ou non", () => {
// La petite démonstration, avec une réservation, un titre et un retenu :
// chaque liste du schéma y figure, à chaque profondeur.
const charge = petite();
charge.reservations = [{ participant: 2, table: 1, siege: null, portee: 'tous', tour: null }];
charge.titres = [{ table: 1, siege: 1, libelle: 'animation' }];
charge.retenu = retenuDe(charge.propositions[0]);
const canonique = canoniser(charge);
const listes = cheminsDesListes(canonique);
// Le relevé descend jusqu'aux réserves et aux listes de table, retenu
// compris : il n'est pas vide (§ 14.2).
const profondes = [
['titres'],
['propositions', 0, 'placement', 3, 'reserve'],
['retenu', 'placement', 0, 'sieges', 3],
];
for (const attendue of profondes) {
assert.ok(listes.some((chemin) => JSON.stringify(chemin) === JSON.stringify(attendue)), JSON.stringify(attendue));
}
// L'enregistrement 99 de chaque liste d'enregistrements, de la forme de
// ses voisins ; ailleurs, l'objet { id: 99 }.
const ENREGISTREMENTS = new Map(
['participants', 'tables', 'propositions'].map((cle) => [cle, { ...canonique[cle][0], id: 99 }]),
);
const ecarts = listes.flatMap((chemin) =>
['telle quelle', 'vidée'].flatMap((etat) => {
const variante = copie(canonique);
if (etat === 'vidée') chemin.reduce((valeur, etape) => valeur[etape], variante).length = 0;
const enregistrement = chemin.length === 1 && ENREGISTREMENTS.has(chemin[0]);
const valeur = enregistrement ? ENREGISTREMENTS.get(chemin[0]) : { id: 99 };
const insertion = { op: 'poser', chemin: [...chemin, { id: 99 }], valeur };
const attendu = enregistrement ? 'aucun refus' : { rang: 0 };
const refus = JSON.stringify(refusDe(geler(variante), [insertion]));
return refus === JSON.stringify(attendu) ? [] : [`${JSON.stringify(chemin)} ${etat} : ${refus}`];
}),
);
assert.deepEqual(ecarts, []);
});
test("une valeur posée suit la règle de sa place : à chaque place, propositions et retenu compris, une valeur d'une autre sorte est refusée et la sienne admise ; à la place du retenu, un retenu hors de sa règle reste admis", () => {
// La petite démonstration, avec une date, une filiation, une
// réservation, un titre et un retenu : chaque règle du schéma y a une
// valeur à éprouver.
const charge = petite();
charge.evenement.date = '2031-05-17';
charge.evenement.filiation = {
source: { id: 'evt-source', nom: 'Soirée source' },
instant: { revision: 3, libelle: 'Création' },
};
charge.reservations = [{ participant: 2, table: 1, siege: 1, portee: 'tour', tour: 2 }];
charge.titres = [{ table: 1, siege: 1, libelle: 'animation' }];
charge.retenu = retenuDe(charge.propositions[0]);
const canonique = geler(canoniser(charge));
const valeurs = valeursDe(canonique);
// Le relevé descend jusqu'aux feuilles, celles du retenu comprises : il
// n'est pas vide (§ 14.2).
const profonds = [
['evenement', 'filiation', 'instant', 'libelle'],
['reservations', 0, 'tour'],
['retenu', 'placement', 1, 'sieges', 2, 0],
];
for (const attendu of profonds) {
assert.ok(valeurs.some(({ chemin }) => JSON.stringify(chemin) === JSON.stringify(attendu)), JSON.stringify(attendu));
}
// Un retenu que l'analyse admet et que le contrôle des placements juge
// fautif se pose à sa place comme elle le lit : une entrée de journal
// qui le remet en place se rejoue.
const retenuFautif = { ...retenuDe(charge.propositions[0]), tours: 0 };
assert.equal(refusDe(canonique, [{ op: 'poser', chemin: ['retenu'], valeur: retenuFautif }]), 'aucun refus');
const ecarts = valeurs.flatMap(({ chemin, valeur }) => {
const autre = Array.isArray(valeur) ? {} : [];
return [
['sa valeur', valeur, 'aucun refus'],
["une valeur d'une autre sorte", autre, { rang: 0 }],
].flatMap(([cas, posee, attendu]) => {
const refus = JSON.stringify(refusDe(canonique, [{ op: 'poser', chemin, valeur: posee }]));
return refus === JSON.stringify(attendu) ? [] : [`${JSON.stringify(chemin)}, ${cas} : ${refus}`];
});
});
assert.deepEqual(ecarts, []);
});
test("les opérations s'appliquent dans l'ordre : celle qu'une précédente rend caduque est refusée à son rang", () => {
const charge = geler(petite());
const retrait = { op: 'retirer', chemin: ['participants', { id: 3 }] };
assert.deepEqual(
refusDe(charge, [retrait, { op: 'poser', chemin: ['participants', { id: 3 }, 'nom'], valeur: 'Brindille' }]),
{ rang: 1 },
);
assert.deepEqual(refusDe(charge, [retrait, retrait]), { rang: 1 });
// Retirer puis reposer le même identifiant s'applique, à la même place.
const remplacant = { ...copie(charge.participants[2]), nom: 'Brindille' };
const repose = { op: 'poser', chemin: ['participants', { id: 3 }], valeur: remplacant };
const rendu = appliquer(charge, [retrait, repose]);
assert.deepEqual(rendu.participants[2], remplacant);
});
test("un correctif qui n'est pas une liste d'opérations lève TypeError, ensemble d'opérations compris", () => {
const operation = { op: 'poser', chemin: ['evenement', 'nom'], valeur: 'x' };
for (const correctif of [null, undefined, operation, 'poser', new Set([operation])]) {
assert.throws(() => appliquer(petite(), correctif), TypeError);
}
});
});
describe('le marqueur de chaise vide : correctifs et forme canonique (§ 8.9)', () => {
// La petite démonstration, sa proposition 1 à sièges attribués, retenue
// telle quelle : au tour 1, la table 1 assied 9, 5 et 1, dans cet ordre
// de sièges. Une charge neuve à chaque appel.
function retenuAttribue() {
const charge = chargeDe('petite', [{ id: 1, siegesAttribues: true }]);
charge.propositions[0].placement[0].sieges[0] = [9, 5, 1];
charge.retenu = retenuDe(charge.propositions[0]);
charge.evenement.etat = 'retenu';
return charge;
}
// retenuAttribue, la personne 5 retirée du tour 1 du retenu : sa chaise,
// le siège 2 de la table 1, est libre, et elle attend en réserve.
function chaiseLiberee() {
const charge = retenuAttribue();
charge.retenu.placement[0].sieges[0] = [9, null, 1];
charge.retenu.placement[0].reserve = [5];
return charge;
}
test("libérer une chaise du retenu : une seule pose dans la liste, valeur null, au rang de la chaise ; appliquer rend la charge d'après, que canoniser garde dans l'ordre des sièges", () => {
const a = geler(retenuAttribue());
const b = geler(chaiseLiberee());
assert.deepStrictEqual(fautes(b.retenu), []);
const correctif = difference(a, b);
assert.deepStrictEqual(correctif, [
{ op: 'poser', chemin: ['retenu', 'placement', 0, 'sieges', 0, 1], valeur: null },
{ op: 'poser', chemin: ['retenu', 'placement', 0, 'reserve'], valeur: [5] },
]);
const rendue = appliquer(a, correctif);
assert.deepStrictEqual(rendue, canoniser(b));
assert.deepStrictEqual(rendue.retenu.placement[0].sieges[0], [9, null, 1]);
assert.equal(serialiserCharge(rendue), serialiserCharge(b));
assert.deepStrictEqual(analyser(serialiser(rendue, ENTETE)).charge.retenu, rendue.retenu);
// Le retour : la chaise reprend son occupant, par une pose au même rang.
const retour = difference(b, a);
assert.deepStrictEqual(retour, [
{ op: 'poser', chemin: ['retenu', 'placement', 0, 'sieges', 0, 1], valeur: 5 },
{ op: 'poser', chemin: ['retenu', 'placement', 0, 'reserve'], valeur: [] },
]);
assert.deepStrictEqual(appliquer(b, retour), canoniser(a));
});
test("dans une proposition aussi, une chaise libérée est une pose au rang de la chaise ; une liste qui change à chaque rang se pose entière, chaises vides comprises", () => {
const a = geler(retenuAttribue());
const b = retenuAttribue();
b.propositions[0].placement[0].sieges[0] = [9, null, 1];
b.propositions[0].placement[0].reserve = [5];
assert.deepStrictEqual(difference(a, geler(b)), [
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0, 'sieges', 0, 1], valeur: null },
{ op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0, 'reserve'], valeur: [5] },
]);
const c = chaiseLiberee();
c.retenu.placement[0].sieges[0] = [1, null, 9];
const correctif = difference(a, geler(c));
assert.deepStrictEqual(correctif, [
{ op: 'poser', chemin: ['retenu', 'placement', 0, 'sieges', 0], valeur: [1, null, 9] },
{ op: 'poser', chemin: ['retenu', 'placement', 0, 'reserve'], valeur: [5] },
]);
assert.deepStrictEqual(appliquer(a, correctif), canoniser(c));
});
test("appliquer admet null dans une liste de table, comme la règle, et le refuse ailleurs dans un placement : réserve, tables, participants, capacités", () => {
const base = geler(chaiseLiberee());
const admises = [
['retenu', 'placement', 1, 'sieges', 2, 1],
['propositions', { id: 1 }, 'placement', 2, 'sieges', 0, 1],
];
for (const chemin of admises) {
assert.equal(refusDe(base, [{ op: 'poser', chemin, valeur: null }]), 'aucun refus', JSON.stringify(chemin));
}
const refusees = [
['retenu', 'placement', 0, 'reserve', 0],
['retenu', 'tables', 0],
['retenu', 'participants', 0],
['retenu', 'capacites', 0],
['propositions', { id: 1 }, 'placement', 1, 'sieges', 0],
['propositions', { id: 1 }, 'participants', 3],
];
assert.ok(refusees.length > 0);
for (const chemin of refusees) {
assert.deepEqual(refusDe(base, [{ op: 'poser', chemin, valeur: null }]), { rang: 0 }, JSON.stringify(chemin));
}
});
});