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
1028 lines
52 KiB
JavaScript
1028 lines
52 KiB
JavaScript
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||
|
||
// Épreuves du document d'événement (§ 4, § 6.1, § 8.1, § 8.8, § 9) : la
|
||
// charge neuve ; l'analyse d'un texte, ce qu'elle lit et chacun de ses
|
||
// refus, nommé par sa raison et son chemin ; la lecture d'un format plus
|
||
// récent ; la configuration que reçoit le moteur ; l'état qu'impose le
|
||
// contenu ; la capacité d'une table ; l'erreur de stockage ; le parcours
|
||
// de forme que partagent l'analyse, le contrôle des placements et les
|
||
// correctifs. Les textes d'épreuve sont du JSON compact : l'analyse ne
|
||
// dépend pas de la mise en page. Les noms sont inventés.
|
||
import assert from 'node:assert/strict';
|
||
import { describe, test } from '../../test/lanceur.js';
|
||
import { STATUT, normaliser } from '../moteur/configuration.js';
|
||
import { ErreurConfiguration } from '../moteur/erreurs.js';
|
||
import { HISTORIQUE_PAR_DEFAUT } from '../moteur/recherche.js';
|
||
import { texteLivre } from '../demo/livrees.js';
|
||
import { VERSION } from '../version.genere.js';
|
||
import { serialiser } from './canonique.js';
|
||
import {
|
||
FORMAT,
|
||
GENERATION_PAR_DEFAUT,
|
||
SCHEMA,
|
||
analyser,
|
||
capacite,
|
||
configurationDepuisCharge,
|
||
creerCharge,
|
||
etatDeduit,
|
||
premiereFaute,
|
||
premiereFauteEnPlace,
|
||
} from './document.js';
|
||
import { ErreurStockage } from './erreurs.js';
|
||
|
||
// Un document valide : quatre participants, dont un exclu, l'identifiant 4
|
||
// retiré et jamais réattribué ; deux tables, dont une surchargée ; une
|
||
// réservation de portée « tous » à siège donné et une de tour désigné sans
|
||
// siège ; un titre ; une proposition ; une filiation. Une copie neuve à
|
||
// chaque appel, que chaque épreuve abîme à sa façon.
|
||
function documentValide() {
|
||
return {
|
||
entete: {
|
||
format: 1,
|
||
produitVersion: VERSION.affichee,
|
||
revision: 12,
|
||
comptes: { participants: 4, tables: 2, reservations: 2, titres: 1, propositions: 1, retenu: 0 },
|
||
},
|
||
charge: {
|
||
evenement: {
|
||
id: 'evt-lucioles',
|
||
nom: 'Veillée des Lucioles',
|
||
date: '2026-11-12',
|
||
siegesParDefaut: 3,
|
||
tours: 2,
|
||
unite: 'cm',
|
||
etat: 'propose',
|
||
filiation: {
|
||
source: { id: 'evt-modele', nom: 'Veillée modèle' },
|
||
instant: { revision: 7, libelle: 'Tables posées' },
|
||
},
|
||
},
|
||
reglages: {
|
||
separerAppartenances: true,
|
||
nouveauxVoisins: false,
|
||
nouvelleTable: true,
|
||
varierAppartenances: false,
|
||
attribuerSieges: false,
|
||
generation: { nombre: 3, arret: 50_000, historique: 500 },
|
||
},
|
||
prochainsIds: { participant: 6, table: 4, proposition: 2 },
|
||
participants: [
|
||
{ id: 1, nom: 'Brindille', prenom: 'Anouk', appartenance: 'Chorale du Vallon',
|
||
courriel: 'anouk@exemple.test', titrePressenti: 'animation', notes: null, exclu: false },
|
||
{ id: 2, nom: 'Sarrasin', prenom: null, appartenance: 'Chorale du Vallon', courriel: null,
|
||
titrePressenti: null, notes: 'part à 21 h', exclu: false },
|
||
{ id: 3, nom: 'Coquelicot', prenom: 'Basile', appartenance: null, courriel: null,
|
||
titrePressenti: null, notes: null, exclu: true },
|
||
{ id: 5, nom: 'Mirabelle', prenom: 'Ysé', appartenance: 'Cercle Gamma', courriel: null,
|
||
titrePressenti: null, notes: null, exclu: false },
|
||
],
|
||
tables: [
|
||
{ id: 1, numero: 1, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } },
|
||
{ id: 3, numero: 2, sieges: 4, forme: 'carree', position: { x: 120.5, y: -40 } },
|
||
],
|
||
reservations: [
|
||
{ participant: 1, table: 3, siege: 4, portee: 'tous', tour: null },
|
||
{ participant: 2, table: 1, siege: null, portee: 'tour', tour: 2 },
|
||
],
|
||
titres: [{ table: 3, siege: 4, libelle: 'animation' }],
|
||
propositions: [
|
||
{
|
||
id: 1,
|
||
graine: 7,
|
||
arret: 50_000,
|
||
historique: 500,
|
||
produitVersion: VERSION.affichee,
|
||
siegesAttribues: false,
|
||
tables: [1, 3],
|
||
capacites: [3, 4],
|
||
tours: 2,
|
||
participants: [1, 2, 5],
|
||
placement: [
|
||
{ sieges: [[2, 5], [1]], reserve: [] },
|
||
{ sieges: [[2], [1, 5]], reserve: [] },
|
||
],
|
||
},
|
||
],
|
||
retenu: null,
|
||
},
|
||
};
|
||
}
|
||
|
||
// Un retenu bien formé pour le document valide.
|
||
const retenuValide = () => ({
|
||
proposition: 1,
|
||
siegesAttribues: false,
|
||
tables: [1, 3],
|
||
capacites: [3, 4],
|
||
tours: 2,
|
||
participants: [1, 2, 5],
|
||
placement: [
|
||
{ sieges: [[2, 5], [1]], reserve: [] },
|
||
{ sieges: [[2], [1, 5]], reserve: [] },
|
||
],
|
||
});
|
||
|
||
// Texte du document valide, une fois modifier passé sur lui.
|
||
function abime(modifier) {
|
||
const copie = documentValide();
|
||
modifier(copie);
|
||
return JSON.stringify(copie);
|
||
}
|
||
|
||
// texte où avant, qui doit y figurer exactement une fois, devient apres :
|
||
// pour une valeur que JSON.stringify n'écrit pas. Un remplacement qui ne
|
||
// trouve rien échoue au lieu de rendre le texte inchangé.
|
||
function remplacerUneFois(texte, avant, apres) {
|
||
assert.equal(texte.split(avant).length, 2, `« ${avant} » doit figurer une fois`);
|
||
return texte.replace(avant, () => apres);
|
||
}
|
||
|
||
// Écart entre ce que fait analyser(texte) et le refus ETAT_ILLISIBLE dont
|
||
// les détails sont la raison et le chemin attendus, puis ceux de plus ; null
|
||
// quand ils concordent.
|
||
function ecartDeRefus(texte, raison, chemin, plus = {}) {
|
||
const attendus = { raison, chemin, ...plus };
|
||
try {
|
||
analyser(texte);
|
||
} catch (erreur) {
|
||
if (!(erreur instanceof ErreurStockage)) {
|
||
return `${erreur?.name} au lieu d'une ErreurStockage : ${erreur?.message}`;
|
||
}
|
||
if (erreur.code !== 'ETAT_ILLISIBLE') return `code ${erreur.code} au lieu de ETAT_ILLISIBLE`;
|
||
try {
|
||
assert.deepEqual(erreur.details, attendus);
|
||
} catch {
|
||
return `détails ${JSON.stringify(erreur.details)} au lieu de ${JSON.stringify(attendus)}`;
|
||
}
|
||
return null;
|
||
}
|
||
return 'aucun refus';
|
||
}
|
||
|
||
// Chaque cas [libellé, texte, raison, chemin, détails en plus] dont le refus
|
||
// s'écarte de l'attendu, en une ligne lisible.
|
||
function ecartsDeRefus(cas) {
|
||
assert.ok(cas.length > 0, 'aucun cas examiné');
|
||
return cas.flatMap(([libelle, texte, raison, chemin, plus]) => {
|
||
const ecart = ecartDeRefus(texte, raison, chemin, plus);
|
||
return ecart === null ? [] : [`${libelle} : ${ecart}`];
|
||
});
|
||
}
|
||
|
||
// Étapes — clés d'objet et rangs de liste — menant à chaque valeur que
|
||
// l'analyse examine dans valeur, parents avant enfants, hors de l'intérieur
|
||
// des propositions et du retenu, que l'analyse laisse au contrôle des
|
||
// placements.
|
||
function etapesDe(valeur, prefixe = []) {
|
||
const aPart =
|
||
prefixe.length === 2 && prefixe[0] === 'charge' && (prefixe[1] === 'propositions' || prefixe[1] === 'retenu');
|
||
if (aPart || typeof valeur !== 'object' || valeur === null) return [];
|
||
const suivantes = Array.isArray(valeur) ? valeur.map((_, rang) => rang) : Object.keys(valeur);
|
||
return suivantes.flatMap((etape) => [[...prefixe, etape], ...etapesDe(valeur[etape], [...prefixe, etape])]);
|
||
}
|
||
|
||
// Chemin d'ETAT_ILLISIBLE de ces étapes : clés séparées par un point, rangs
|
||
// entre crochets.
|
||
const cheminDe = (etapes) =>
|
||
etapes.map((etape, i) => (typeof etape === 'number' ? `[${etape}]` : i === 0 ? etape : `.${etape}`)).join('');
|
||
|
||
// Valeur au bout de ces étapes dans racine.
|
||
const valeurEn = (racine, etapes) => etapes.reduce((valeur, etape) => valeur[etape], racine);
|
||
|
||
// Chemin de ces étapes, rangs effacés : charge.participants[].nom.
|
||
const sansRangs = (etapes) => cheminDe(etapes).replace(/\[\d+\]/g, '[]');
|
||
|
||
// Chemins, rangs effacés, des valeurs que le contrat de données admet
|
||
// nulles. La liste s'écrit d'après le contrat, sans lire le schéma : un
|
||
// champ que le schéma rendrait nullable à tort reste ainsi éprouvé.
|
||
const ADMETTENT_NULL = new Set([
|
||
'charge.evenement.date',
|
||
'charge.evenement.filiation',
|
||
'charge.participants[].prenom',
|
||
'charge.participants[].appartenance',
|
||
'charge.participants[].courriel',
|
||
'charge.participants[].titrePressenti',
|
||
'charge.participants[].notes',
|
||
'charge.tables[].sieges',
|
||
'charge.reservations[].siege',
|
||
'charge.reservations[].tour',
|
||
'charge.retenu',
|
||
]);
|
||
|
||
// Gèle valeur et tout ce qu'elle contient : une écriture y lève TypeError,
|
||
// le module s'exécutant en mode strict.
|
||
function geler(valeur) {
|
||
if (typeof valeur === 'object' && valeur !== null) {
|
||
for (const enfant of Object.values(valeur)) geler(enfant);
|
||
Object.freeze(valeur);
|
||
}
|
||
return valeur;
|
||
}
|
||
|
||
describe('ErreurStockage (erreurs.js)', () => {
|
||
test('porte son code et ses détails ; le message les écrit, code puis détails en JSON', () => {
|
||
const erreur = new ErreurStockage('ABSENT', { chemin: 'veillee.gtt.json' });
|
||
assert.ok(erreur instanceof Error);
|
||
assert.equal(erreur.name, 'ErreurStockage');
|
||
assert.equal(erreur.code, 'ABSENT');
|
||
assert.deepEqual(erreur.details, { chemin: 'veillee.gtt.json' });
|
||
assert.equal(erreur.message, 'ABSENT {"chemin":"veillee.gtt.json"}');
|
||
const sansDetails = new ErreurStockage('CORRECTIF');
|
||
assert.deepEqual(sansDetails.details, {});
|
||
assert.equal(sansDetails.message, 'CORRECTIF {}');
|
||
});
|
||
|
||
test("le message d'un détail en octets n'en dit que le nombre : les octets d'un fichier entier ne s'écrivent pas dans une trace", () => {
|
||
const octets = new Uint8Array(1 << 20);
|
||
const erreur = new ErreurStockage('LECTURE', { chemin: 'veillee.gtt-journal.jsonl', cause: 'UTF8_INVALIDE', octets });
|
||
assert.equal(erreur.details.octets, octets);
|
||
assert.equal(
|
||
erreur.message,
|
||
'LECTURE {"chemin":"veillee.gtt-journal.jsonl","cause":"UTF8_INVALIDE","octets":"1048576 octets"}',
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('creerCharge (§ 4, § 6.1, § 9)', () => {
|
||
test('une charge neuve est un brouillon sans participant ni table, au format 1, aux réglages par défaut', () => {
|
||
assert.equal(FORMAT, 1);
|
||
const charge = creerCharge({ id: 'evt-neuf', nom: 'Atelier du jeudi', date: '2026-11-12', siegesParDefaut: 8, tours: 3 });
|
||
assert.deepEqual(charge, {
|
||
evenement: {
|
||
id: 'evt-neuf',
|
||
nom: 'Atelier du jeudi',
|
||
date: '2026-11-12',
|
||
siegesParDefaut: 8,
|
||
tours: 3,
|
||
unite: 'cm',
|
||
etat: 'brouillon',
|
||
filiation: null,
|
||
},
|
||
reglages: {
|
||
separerAppartenances: true,
|
||
nouveauxVoisins: true,
|
||
nouvelleTable: true,
|
||
varierAppartenances: true,
|
||
attribuerSieges: false,
|
||
generation: { nombre: 5, arret: 200_000, historique: HISTORIQUE_PAR_DEFAUT },
|
||
},
|
||
prochainsIds: { participant: 1, table: 1, proposition: 1 },
|
||
participants: [],
|
||
tables: [],
|
||
reservations: [],
|
||
titres: [],
|
||
propositions: [],
|
||
retenu: null,
|
||
});
|
||
});
|
||
|
||
test("GENERATION_PAR_DEFAUT : cinq propositions de 200 000 mouvements, l'historique par défaut du moteur ; figée, copiée dans chaque charge neuve", () => {
|
||
assert.deepEqual(GENERATION_PAR_DEFAUT, { nombre: 5, arret: 200_000, historique: HISTORIQUE_PAR_DEFAUT });
|
||
assert.ok(Object.isFrozen(GENERATION_PAR_DEFAUT));
|
||
const { generation } = creerCharge({ id: 'evt-neuf', nom: 'Atelier', siegesParDefaut: 8, tours: 3 }).reglages;
|
||
assert.deepEqual(generation, GENERATION_PAR_DEFAUT);
|
||
assert.notEqual(generation, GENERATION_PAR_DEFAUT);
|
||
assert.ok(!Object.isFrozen(generation));
|
||
});
|
||
|
||
test('sans date, la date est nulle ; deux charges neuves ne partagent aucun objet', () => {
|
||
const premiere = creerCharge({ id: 'evt-a', nom: 'Veillée A', siegesParDefaut: 6, tours: 2 });
|
||
const seconde = creerCharge({ id: 'evt-b', nom: 'Veillée B', siegesParDefaut: 6, tours: 2 });
|
||
assert.equal(premiere.evenement.date, null);
|
||
premiere.reglages.generation.nombre = 9;
|
||
premiere.prochainsIds.participant = 2;
|
||
premiere.participants.push({ id: 1 });
|
||
assert.equal(seconde.reglages.generation.nombre, 5);
|
||
assert.equal(seconde.prochainsIds.participant, 1);
|
||
assert.deepEqual(seconde.participants, []);
|
||
});
|
||
|
||
test("une charge neuve s'écrit, se relit à l'identique, et son état déduit est brouillon", () => {
|
||
const charge = creerCharge({ id: 'evt-neuf', nom: 'Atelier du jeudi', siegesParDefaut: 8, tours: 3 });
|
||
const lecture = analyser(serialiser(charge, { revision: 1, produitVersion: VERSION.affichee }));
|
||
assert.deepEqual(lecture.charge, charge);
|
||
assert.equal(lecture.formatPlusRecent, false);
|
||
assert.equal(etatDeduit(lecture.charge), 'brouillon');
|
||
});
|
||
});
|
||
|
||
describe('analyser : ce qui se lit (§ 8.8)', () => {
|
||
test('le document valide se lit : son en-tête et sa charge tels que le texte les porte, au format courant', () => {
|
||
const { entete, charge } = documentValide();
|
||
assert.deepEqual(analyser(JSON.stringify(documentValide())), {
|
||
entete,
|
||
charge,
|
||
formatPlusRecent: false,
|
||
manquants: { propositions: 0, retenu: false },
|
||
});
|
||
});
|
||
|
||
test('variantes admises : chacune se lit sans refus', () => {
|
||
const variantes = [
|
||
['date nulle', abime((d) => { d.charge.evenement.date = null; })],
|
||
["29 février d'une année bissextile", abime((d) => { d.charge.evenement.date = '2024-02-29'; })],
|
||
['29 février 2000, séculaire divisible par 400', abime((d) => { d.charge.evenement.date = '2000-02-29'; })],
|
||
['31 décembre', abime((d) => { d.charge.evenement.date = '2026-12-31'; })],
|
||
['filiation nulle', abime((d) => { d.charge.evenement.filiation = null; })],
|
||
['table de deux sièges', abime((d) => {
|
||
d.charge.tables[1].sieges = 2;
|
||
d.charge.reservations[0].siege = 2;
|
||
d.charge.titres[0].siege = 1;
|
||
})],
|
||
["réservation d'une personne exclue", abime((d) => {
|
||
d.charge.reservations.push({ participant: 3, table: 1, siege: null, portee: 'tous', tour: null });
|
||
d.entete.comptes.reservations = 3;
|
||
})],
|
||
['réservation au dernier tour', abime((d) => { d.charge.reservations[1].tour = 2; })],
|
||
["texte précédé d'une marque d'ordre d'octets", `\uFEFF${JSON.stringify(documentValide())}`],
|
||
['texte mis en page autrement', JSON.stringify(documentValide(), null, 4)],
|
||
["propositions fautives, que l'analyse ne contrôle pas", abime((d) => {
|
||
const [premiere] = d.charge.propositions;
|
||
d.charge.propositions.push(42, { id: 1, inconnue: true }, { ...premiere, placement: [{ sieges: [[9]] }] });
|
||
d.entete.comptes.propositions = 4;
|
||
})],
|
||
['retenu quelconque', abime((d) => {
|
||
d.charge.retenu = { inconnue: [] };
|
||
d.entete.comptes.retenu = 1;
|
||
})],
|
||
// Un identifiant de proposition qui n'en est pas un ne s'attribue pas :
|
||
// il ne se compare pas à prochainsIds.proposition, et le contrôle des
|
||
// placements écarte la proposition ou signale le retenu.
|
||
["identifiants de proposition qui n'en sont pas, au-delà de prochainsIds.proposition", abime((d) => {
|
||
d.charge.propositions.push(null, { id: 'neuf' }, { id: 9.5 }, { id: 2 ** 31 }, { id: 2 ** 53 }, [9]);
|
||
d.entete.comptes.propositions = 7;
|
||
d.charge.retenu = { ...retenuValide(), proposition: '9' };
|
||
d.entete.comptes.retenu = 1;
|
||
})],
|
||
['deux propositions de même identifiant, sous prochainsIds.proposition', abime((d) => {
|
||
d.charge.propositions.push({ ...d.charge.propositions[0], graine: 8 });
|
||
d.entete.comptes.propositions = 2;
|
||
})],
|
||
['retenu bien formé', abime((d) => {
|
||
d.charge.retenu = retenuValide();
|
||
d.entete.comptes.retenu = 1;
|
||
})],
|
||
['compteurs au plus grand identifiant, 2^31 − 1, et une personne juste en deçà', abime((d) => {
|
||
d.charge.prochainsIds = { participant: 2 ** 31 - 1, table: 2 ** 31 - 1, proposition: 2 ** 31 - 1 };
|
||
d.charge.participants[3].id = 2 ** 31 - 2;
|
||
})],
|
||
['toutes les listes vides', abime((d) => {
|
||
for (const cle of ['participants', 'tables', 'reservations', 'titres', 'propositions']) {
|
||
d.charge[cle] = [];
|
||
d.entete.comptes[cle] = 0;
|
||
}
|
||
})],
|
||
];
|
||
const refusees = variantes.flatMap(([libelle, texte]) => {
|
||
try {
|
||
analyser(texte);
|
||
return [];
|
||
} catch (erreur) {
|
||
return [`${libelle} : ${erreur.message}`];
|
||
}
|
||
});
|
||
assert.deepEqual(refusees, []);
|
||
});
|
||
});
|
||
|
||
describe('analyser : les refus que le plan nomme (§ 8.8)', () => {
|
||
test('vide, JSON, clé inconnue, identifiant égal à prochainsIds, comptes, référence, format 0 : chacun avec sa raison et son chemin', () => {
|
||
assert.deepEqual(ecartsDeRefus([
|
||
['texte vide', '', 'VIDE', null],
|
||
['« { »', '{', 'JSON', null],
|
||
['clé inconnue dans un participant',
|
||
abime((d) => { d.charge.participants[1].surnom = 'Sasa'; }), 'FORME', 'charge.participants[1].surnom'],
|
||
['identifiant de participant égal à prochainsIds.participant',
|
||
abime((d) => { d.charge.participants[3].id = 6; }), 'FORME', 'charge.participants[3].id'],
|
||
['comptes faux',
|
||
abime((d) => { d.entete.comptes.participants = 5; }), 'COMPTES', 'entete.comptes.participants'],
|
||
['réservation vers la personne 9, absente',
|
||
abime((d) => { d.charge.reservations[0].participant = 9; }), 'REFERENCE', 'charge.reservations[0].participant'],
|
||
['format 0', abime((d) => { d.entete.format = 0; }), 'FORMAT_INCONNU', 'entete.format'],
|
||
]), []);
|
||
});
|
||
});
|
||
|
||
describe('analyser : texte vide, JSON, racine et format', () => {
|
||
test('chaque refus porte sa raison et son chemin ; null quand aucun élément ne se désigne', () => {
|
||
const valide = JSON.stringify(documentValide());
|
||
assert.deepEqual(ecartsDeRefus([
|
||
['blancs seuls', ' \n\t\r\n ', 'VIDE', null],
|
||
["marque d'ordre d'octets suivie de blancs", '\uFEFF \n', 'VIDE', null],
|
||
['texte tronqué', valide.slice(0, -20), 'JSON', null],
|
||
['deux documents à la suite', `${valide}${valide}`, 'JSON', null],
|
||
['racine qui est une liste', '[]', 'FORME', ''],
|
||
['racine nulle', 'null', 'FORME', ''],
|
||
['racine qui est un nombre', '3', 'FORME', ''],
|
||
['en-tête absent', abime((d) => { delete d.entete; }), 'FORME', 'entete'],
|
||
['en-tête qui est un nombre', abime((d) => { d.entete = 1; }), 'FORME', 'entete'],
|
||
['format absent', abime((d) => { delete d.entete.format; }), 'FORME', 'entete.format'],
|
||
['format négatif', abime((d) => { d.entete.format = -1; }), 'FORMAT_INCONNU', 'entete.format'],
|
||
['format non entier', abime((d) => { d.entete.format = 1.5; }), 'FORMAT_INCONNU', 'entete.format'],
|
||
['format en texte', abime((d) => { d.entete.format = '1'; }), 'FORMAT_INCONNU', 'entete.format'],
|
||
['format nul', abime((d) => { d.entete.format = null; }), 'FORMAT_INCONNU', 'entete.format'],
|
||
]), []);
|
||
});
|
||
});
|
||
|
||
describe('analyser : forme (§ 8.8)', () => {
|
||
test('chaque clé du contrat, retirée, est nommée par son chemin', () => {
|
||
const etapes = etapesDe(documentValide()).filter((e) => typeof e.at(-1) === 'string');
|
||
assert.ok(etapes.length >= 100, `${etapes.length} clés seulement`);
|
||
assert.deepEqual(ecartsDeRefus(etapes.map((e) => [
|
||
`sans ${cheminDe(e)}`,
|
||
abime((d) => { delete valeurEn(d, e.slice(0, -1))[e.at(-1)]; }),
|
||
'FORME',
|
||
cheminDe(e),
|
||
])), []);
|
||
});
|
||
|
||
test('une clé inconnue, dans chaque objet que le contrat décrit, racine comprise, est nommée par son chemin', () => {
|
||
const objets = [[], ...etapesDe(documentValide())].filter((e) => {
|
||
const valeur = valeurEn(documentValide(), e);
|
||
return typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
|
||
});
|
||
assert.ok(objets.length >= 20, `${objets.length} objets seulement`);
|
||
assert.deepEqual(ecartsDeRefus(objets.map((e) => [
|
||
`inconnue sous ${cheminDe(e) || 'la racine'}`,
|
||
abime((d) => { valeurEn(d, e).inconnue = 1; }),
|
||
'FORME',
|
||
cheminDe([...e, 'inconnue']),
|
||
])), []);
|
||
});
|
||
|
||
test("chaque valeur remplacée par une valeur d'une autre sorte est nommée par son chemin", () => {
|
||
// Une liste devient un objet, toute autre valeur une liste : aucune règle
|
||
// du contrat n'admet l'un pour l'autre. Le format, lu avant tout le reste,
|
||
// a sa propre raison.
|
||
const etapes = etapesDe(documentValide());
|
||
assert.deepEqual(ecartsDeRefus(etapes.map((e) => {
|
||
const chemin = cheminDe(e);
|
||
return [
|
||
`${chemin} d'une autre sorte`,
|
||
abime((d) => {
|
||
const porteur = valeurEn(d, e.slice(0, -1));
|
||
porteur[e.at(-1)] = Array.isArray(porteur[e.at(-1)]) ? {} : [];
|
||
}),
|
||
chemin === 'entete.format' ? 'FORMAT_INCONNU' : 'FORME',
|
||
chemin,
|
||
];
|
||
})), []);
|
||
});
|
||
|
||
test('chaque valeur hors de son domaine est nommée par son chemin', () => {
|
||
const cas = [
|
||
['produitVersion vide', (d) => { d.entete.produitVersion = ''; }, 'entete.produitVersion'],
|
||
['révision 0', (d) => { d.entete.revision = 0; }, 'entete.revision'],
|
||
['révision non entière', (d) => { d.entete.revision = 2.5; }, 'entete.revision'],
|
||
['compte négatif', (d) => { d.entete.comptes.titres = -1; }, 'entete.comptes.titres'],
|
||
['compte de retenu 2', (d) => { d.entete.comptes.retenu = 2; }, 'entete.comptes.retenu'],
|
||
["identifiant d'événement vide", (d) => { d.charge.evenement.id = ''; }, 'charge.evenement.id'],
|
||
["nom d'événement vide", (d) => { d.charge.evenement.nom = ''; }, 'charge.evenement.nom'],
|
||
['30 février', (d) => { d.charge.evenement.date = '2026-02-30'; }, 'charge.evenement.date'],
|
||
["29 février d'une année ordinaire", (d) => { d.charge.evenement.date = '2026-02-29'; }, 'charge.evenement.date'],
|
||
['29 février 1900, séculaire', (d) => { d.charge.evenement.date = '1900-02-29'; }, 'charge.evenement.date'],
|
||
['31 avril', (d) => { d.charge.evenement.date = '2026-04-31'; }, 'charge.evenement.date'],
|
||
['mois 13', (d) => { d.charge.evenement.date = '2026-13-01'; }, 'charge.evenement.date'],
|
||
['mois 00', (d) => { d.charge.evenement.date = '2026-00-10'; }, 'charge.evenement.date'],
|
||
['jour 00', (d) => { d.charge.evenement.date = '2026-11-00'; }, 'charge.evenement.date'],
|
||
['date dans un autre ordre', (d) => { d.charge.evenement.date = '12/11/2026'; }, 'charge.evenement.date'],
|
||
['date suivie d\'une heure', (d) => { d.charge.evenement.date = '2026-11-12T19:00'; }, 'charge.evenement.date'],
|
||
['date vide', (d) => { d.charge.evenement.date = ''; }, 'charge.evenement.date'],
|
||
['un seul siège par défaut', (d) => { d.charge.evenement.siegesParDefaut = 1; }, 'charge.evenement.siegesParDefaut'],
|
||
['aucun tour', (d) => { d.charge.evenement.tours = 0; }, 'charge.evenement.tours'],
|
||
['unité autre que le centimètre', (d) => { d.charge.evenement.unite = 'mm'; }, 'charge.evenement.unite'],
|
||
['état inconnu', (d) => { d.charge.evenement.etat = 'archive'; }, 'charge.evenement.etat'],
|
||
['source de filiation sans nom', (d) => { d.charge.evenement.filiation.source.nom = ''; },
|
||
'charge.evenement.filiation.source.nom'],
|
||
['instant de filiation à la révision 0', (d) => { d.charge.evenement.filiation.instant.revision = 0; },
|
||
'charge.evenement.filiation.instant.revision'],
|
||
['réglage non booléen', (d) => { d.charge.reglages.attribuerSieges = 'non'; }, 'charge.reglages.attribuerSieges'],
|
||
["compte d'arrêt nul", (d) => { d.charge.reglages.generation.arret = 0; }, 'charge.reglages.generation.arret'],
|
||
['aucune proposition demandée', (d) => { d.charge.reglages.generation.nombre = 0; },
|
||
'charge.reglages.generation.nombre'],
|
||
['historique de longueur 0', (d) => { d.charge.reglages.generation.historique = 0; },
|
||
'charge.reglages.generation.historique'],
|
||
['prochain identifiant 0', (d) => { d.charge.prochainsIds.participant = 0; }, 'charge.prochainsIds.participant'],
|
||
['prochain identifiant de table 0', (d) => { d.charge.prochainsIds.table = 0; }, 'charge.prochainsIds.table'],
|
||
['prochain identifiant de proposition 0', (d) => { d.charge.prochainsIds.proposition = 0; },
|
||
'charge.prochainsIds.proposition'],
|
||
['identifiant 0', (d) => { d.charge.participants[0].id = 0; }, 'charge.participants[0].id'],
|
||
['identifiant en texte', (d) => { d.charge.participants[0].id = '1'; }, 'charge.participants[0].id'],
|
||
// Un identifiant, et chaque compteur qui en est le prochain, reste sous
|
||
// 2^31 ; une référence vers un identifiant au-delà sort de sa règle
|
||
// avant de désigner quoi que ce soit. L'identifiant d'un participant
|
||
// ou d'une table, l'analyse le compare aussi à son compteur, qui le
|
||
// tient déjà sous 2^31 − 1, sous le même chemin : sa règle s'éprouve
|
||
// là où rien ne le compare au compteur, dans les poses d'un correctif
|
||
// (correctifs.test.js).
|
||
['prochain identifiant à 2^31', (d) => { d.charge.prochainsIds.participant = 2 ** 31; },
|
||
'charge.prochainsIds.participant'],
|
||
['prochain identifiant de table à 2^31', (d) => { d.charge.prochainsIds.table = 2 ** 31; },
|
||
'charge.prochainsIds.table'],
|
||
['prochain identifiant de proposition à 2^31', (d) => { d.charge.prochainsIds.proposition = 2 ** 31; },
|
||
'charge.prochainsIds.proposition'],
|
||
['réservation de la personne 2^31', (d) => { d.charge.reservations[0].participant = 2 ** 31; },
|
||
'charge.reservations[0].participant'],
|
||
['réservation de la table 2^31', (d) => { d.charge.reservations[1].table = 2 ** 31; },
|
||
'charge.reservations[1].table'],
|
||
['titre de la table 2^31', (d) => { d.charge.titres[0].table = 2 ** 31; }, 'charge.titres[0].table'],
|
||
['révision au-delà des entiers exacts', (d) => { d.entete.revision = 2 ** 53; }, 'entete.revision'],
|
||
['nom de participant vide', (d) => { d.charge.participants[1].nom = ''; }, 'charge.participants[1].nom'],
|
||
['prénom vide au lieu de null', (d) => { d.charge.participants[1].prenom = ''; }, 'charge.participants[1].prenom'],
|
||
['notes vides au lieu de null', (d) => { d.charge.participants[2].notes = ''; }, 'charge.participants[2].notes'],
|
||
['courriel numérique', (d) => { d.charge.participants[0].courriel = 42; }, 'charge.participants[0].courriel'],
|
||
['exclu en texte', (d) => { d.charge.participants[2].exclu = 'oui'; }, 'charge.participants[2].exclu'],
|
||
['exclu nul', (d) => { d.charge.participants[2].exclu = null; }, 'charge.participants[2].exclu'],
|
||
['numéro de table 0', (d) => { d.charge.tables[0].numero = 0; }, 'charge.tables[0].numero'],
|
||
['table d\'un seul siège', (d) => { d.charge.tables[1].sieges = 1; }, 'charge.tables[1].sieges'],
|
||
['sièges non entiers', (d) => { d.charge.tables[1].sieges = 3.5; }, 'charge.tables[1].sieges'],
|
||
['forme inconnue', (d) => { d.charge.tables[0].forme = 'ovale'; }, 'charge.tables[0].forme'],
|
||
['position en texte', (d) => { d.charge.tables[0].position.x = '0'; }, 'charge.tables[0].position.x'],
|
||
['position nulle', (d) => { d.charge.tables[1].position.y = null; }, 'charge.tables[1].position.y'],
|
||
['portée inconnue', (d) => { d.charge.reservations[0].portee = 'toujours'; }, 'charge.reservations[0].portee'],
|
||
['siège réservé 0', (d) => { d.charge.reservations[0].siege = 0; }, 'charge.reservations[0].siege'],
|
||
['tour 0', (d) => { d.charge.reservations[1].tour = 0; }, 'charge.reservations[1].tour'],
|
||
['libellé de titre vide', (d) => { d.charge.titres[0].libelle = ''; }, 'charge.titres[0].libelle'],
|
||
['titre sans siège', (d) => { d.charge.titres[0].siege = null; }, 'charge.titres[0].siege'],
|
||
];
|
||
assert.deepEqual(ecartsDeRefus(cas.map(([libelle, modifier, chemin]) => [libelle, abime(modifier), 'FORME', chemin])), []);
|
||
});
|
||
|
||
test("une position que JSON.parse lit infinie, 1e400 ou -1e400, est nommée par son chemin", () => {
|
||
// JSON.stringify n'écrit jamais un nombre infini : le cas s'écrit dans le
|
||
// texte, et l'épreuve vérifie d'abord qu'il se lit bien infini.
|
||
const valide = JSON.stringify(documentValide());
|
||
const cas = [
|
||
['abscisse 1e400',
|
||
remplacerUneFois(valide, '"position":{"x":0,"y":0}', '"position":{"x":1e400,"y":0}'),
|
||
'FORME', 'charge.tables[0].position.x'],
|
||
['ordonnée -1e400',
|
||
remplacerUneFois(valide, '"position":{"x":120.5,"y":-40}', '"position":{"x":120.5,"y":-1e400}'),
|
||
'FORME', 'charge.tables[1].position.y'],
|
||
];
|
||
assert.equal(JSON.parse(cas[0][1]).charge.tables[0].position.x, Infinity);
|
||
assert.equal(JSON.parse(cas[1][1]).charge.tables[1].position.y, -Infinity);
|
||
assert.deepEqual(ecartsDeRefus(cas), []);
|
||
});
|
||
|
||
test("null, posé à chaque valeur dont le contrat ne l'admet pas, est nommé par son chemin", () => {
|
||
const etapes = etapesDe(documentValide());
|
||
// Chaque chemin admis nul désigne au moins une valeur du document : la
|
||
// liste suit le contrat, sans entrée qui ne désigne plus rien.
|
||
for (const chemin of ADMETTENT_NULL) {
|
||
assert.ok(etapes.some((e) => sansRangs(e) === chemin), `${chemin} : aucune valeur du document`);
|
||
}
|
||
const refusent = etapes.filter((e) => !ADMETTENT_NULL.has(sansRangs(e)));
|
||
assert.ok(refusent.length >= 80, `${refusent.length} valeurs seulement`);
|
||
// Le format, lu avant tout le reste, a sa propre raison.
|
||
assert.deepEqual(ecartsDeRefus(refusent.map((e) => {
|
||
const chemin = cheminDe(e);
|
||
return [
|
||
`${chemin} nul`,
|
||
abime((d) => { valeurEn(d, e.slice(0, -1))[e.at(-1)] = null; }),
|
||
chemin === 'entete.format' ? 'FORMAT_INCONNU' : 'FORME',
|
||
chemin,
|
||
];
|
||
})), []);
|
||
});
|
||
|
||
test("de plusieurs clés inconnues, la plus petite en unités UTF-16 est nommée, quel que soit l'ordre où elles arrivent", () => {
|
||
const avec = (cles) => abime((d) => {
|
||
for (const cle of cles) d.charge.participants[1][cle] = 1;
|
||
});
|
||
// Une clé « __proto__ » ne s'écrit que dans le texte : une affectation en
|
||
// JavaScript changerait le prototype au lieu d'ajouter une clé.
|
||
const avecProto = JSON.stringify(documentValide()).replace('"nom":"Sarrasin"', '"nom":"Sarrasin","__proto__":{}');
|
||
assert.deepEqual(ecartsDeRefus([
|
||
['zeta puis alpha', avec(['zeta', 'alpha']), 'FORME', 'charge.participants[1].alpha'],
|
||
['alpha puis zeta', avec(['alpha', 'zeta']), 'FORME', 'charge.participants[1].alpha'],
|
||
['une majuscule précède une minuscule', avec(['alpha', 'Zeta']), 'FORME', 'charge.participants[1].Zeta'],
|
||
["une clé qui n'est pas un identifiant", avec(['nom complet']), 'FORME', 'charge.participants[1]["nom complet"]'],
|
||
['une clé vide', avec(['']), 'FORME', 'charge.participants[1][""]'],
|
||
['une clé entière', avec(['7']), 'FORME', 'charge.participants[1]["7"]'],
|
||
['une clé « __proto__ »', avecProto, 'FORME', 'charge.participants[1].__proto__'],
|
||
]), []);
|
||
});
|
||
});
|
||
|
||
describe('analyser : identifiants et tours (§ 4)', () => {
|
||
test('un identifiant en double, ou atteignant prochainsIds, est nommé ; un tour hors de sa portée aussi', () => {
|
||
assert.deepEqual(ecartsDeRefus([
|
||
['identifiant de participant au-delà de prochainsIds',
|
||
abime((d) => { d.charge.participants[3].id = 9; }), 'FORME', 'charge.participants[3].id'],
|
||
['identifiant de participant en double, le second fautif',
|
||
abime((d) => { d.charge.participants[2].id = 1; }), 'FORME', 'charge.participants[2].id'],
|
||
['identifiant de table égal à prochainsIds.table',
|
||
abime((d) => { d.charge.tables[1].id = 4; }), 'FORME', 'charge.tables[1].id'],
|
||
['identifiant de table en double',
|
||
abime((d) => { d.charge.tables[1].id = 1; }), 'FORME', 'charge.tables[1].id'],
|
||
['portée « tous » avec un tour',
|
||
abime((d) => { d.charge.reservations[0].tour = 1; }), 'FORME', 'charge.reservations[0].tour'],
|
||
['portée « tour » sans tour',
|
||
abime((d) => { d.charge.reservations[1].tour = null; }), 'FORME', 'charge.reservations[1].tour'],
|
||
['tour au-delà du nombre de tours',
|
||
abime((d) => { d.charge.reservations[1].tour = 3; }), 'FORME', 'charge.reservations[1].tour'],
|
||
]), []);
|
||
});
|
||
|
||
test("l'identifiant d'une proposition et la proposition d'origine du retenu ne se comparent pas à prochainsIds.proposition : le fichier se lit tel qu'écrit, au format courant comme plus récent, et le contrôle des placements les juge (§ 8.9, point 3)", () => {
|
||
// La proposition 1 est seule, et prochainsIds.proposition vaut 2.
|
||
const avecRetenu = (proposition) => (d) => {
|
||
d.charge.retenu = { ...retenuValide(), proposition };
|
||
d.entete.comptes.retenu = 1;
|
||
};
|
||
const seconde = (id) => (d) => {
|
||
d.charge.propositions.push({ ...d.charge.propositions[0], id });
|
||
d.entete.comptes.propositions = 2;
|
||
};
|
||
const cas = [
|
||
['identifiant de proposition égal à prochainsIds.proposition', (d) => { d.charge.propositions[0].id = 2; }],
|
||
['identifiant de proposition au-delà de prochainsIds.proposition', (d) => { d.charge.propositions[0].id = 7; }],
|
||
['la seconde proposition au-delà, la première en deçà', seconde(3)],
|
||
["proposition d'origine du retenu égale à prochainsIds.proposition", avecRetenu(2)],
|
||
["proposition d'origine du retenu au-delà, sans aucune proposition", (d) => {
|
||
avecRetenu(5)(d);
|
||
d.charge.propositions = [];
|
||
d.entete.comptes.propositions = 0;
|
||
}],
|
||
['compteur abaissé à 1, sous chaque identifiant et sous le retenu', (d) => {
|
||
avecRetenu(1)(d);
|
||
seconde(3)(d);
|
||
d.charge.prochainsIds.proposition = 1;
|
||
}],
|
||
];
|
||
// Chaque cas se lit à chaque format, l'en-tête et la charge rendus tels
|
||
// que le document les porte.
|
||
const ecarts = cas.flatMap(([libelle, modifier]) =>
|
||
[FORMAT, FORMAT + 1].flatMap((format) => {
|
||
const attendu = documentValide();
|
||
modifier(attendu);
|
||
attendu.entete.format = format;
|
||
try {
|
||
assert.deepEqual(analyser(JSON.stringify(attendu)), {
|
||
...attendu,
|
||
formatPlusRecent: format > FORMAT,
|
||
manquants: { propositions: 0, retenu: false },
|
||
});
|
||
return [];
|
||
} catch (erreur) {
|
||
return [`${libelle}, format ${format} : ${erreur.message.split('\n')[0]}`];
|
||
}
|
||
}),
|
||
);
|
||
assert.ok(cas.length > 0, 'aucun cas examiné');
|
||
assert.deepEqual(ecarts, []);
|
||
// Un identifiant de table en double reste refusé, quels que soient ceux
|
||
// des propositions.
|
||
assert.deepEqual(ecartsDeRefus([
|
||
['une table en double, une proposition au-delà du compteur', abime((d) => {
|
||
seconde(3)(d);
|
||
d.charge.tables[1].id = 1;
|
||
}), 'FORME', 'charge.tables[1].id'],
|
||
]), []);
|
||
});
|
||
});
|
||
|
||
describe('analyser : comptes de l\'en-tête (§ 8.8)', () => {
|
||
test('les comptes de la saisie — participants, tables, réservations, titres — se comparent à leur liste et refusent le fichier', () => {
|
||
assert.deepEqual(ecartsDeRefus([
|
||
['un participant perdu', abime((d) => { d.charge.participants.pop(); }), 'COMPTES', 'entete.comptes.participants'],
|
||
['une table annoncée en trop', abime((d) => { d.entete.comptes.tables = 3; }), 'COMPTES', 'entete.comptes.tables'],
|
||
['une réservation annoncée en moins',
|
||
abime((d) => { d.entete.comptes.reservations = 1; }), 'COMPTES', 'entete.comptes.reservations'],
|
||
['un titre annoncé en trop', abime((d) => { d.entete.comptes.titres = 2; }), 'COMPTES', 'entete.comptes.titres'],
|
||
]), []);
|
||
});
|
||
|
||
test("les comptes des propositions et du retenu ne refusent jamais le fichier (§ 8.9, point 3) : manquants rend les propositions annoncées au-delà de la liste et le retenu annoncé absent ; une liste plus longue que son compte se lit telle quelle", () => {
|
||
const manquantsDe = (modifier) => analyser(abime(modifier)).manquants;
|
||
assert.deepEqual(manquantsDe(() => {}), { propositions: 0, retenu: false });
|
||
assert.deepEqual(manquantsDe((d) => { d.charge.propositions = []; }), { propositions: 1, retenu: false });
|
||
assert.deepEqual(manquantsDe((d) => { d.entete.comptes.propositions = 3; }), { propositions: 2, retenu: false });
|
||
assert.deepEqual(manquantsDe((d) => { d.entete.comptes.retenu = 1; }), { propositions: 0, retenu: true });
|
||
assert.deepEqual(manquantsDe((d) => { d.entete.comptes.propositions = 0; }), { propositions: 0, retenu: false });
|
||
const retenuNonAnnonce = analyser(abime((d) => { d.charge.retenu = retenuValide(); }));
|
||
assert.deepEqual(retenuNonAnnonce.manquants, { propositions: 0, retenu: false });
|
||
assert.deepEqual(retenuNonAnnonce.charge.retenu, retenuValide());
|
||
// Au format plus récent de même.
|
||
const plusRecent = analyser(abime((d) => {
|
||
d.entete.format = FORMAT + 1;
|
||
d.charge.propositions = [];
|
||
d.entete.comptes.retenu = 1;
|
||
}));
|
||
assert.deepEqual(plusRecent.manquants, { propositions: 1, retenu: true });
|
||
});
|
||
});
|
||
|
||
describe('analyser : références (§ 4, § 6.2)', () => {
|
||
test("une réservation ou un titre vers une table ou une personne absente, ou un siège au-delà de la capacité, est nommé", () => {
|
||
assert.deepEqual(ecartsDeRefus([
|
||
["réservation d'une personne retirée",
|
||
abime((d) => { d.charge.reservations[1].participant = 4; }), 'REFERENCE', 'charge.reservations[1].participant'],
|
||
['réservation vers une table absente',
|
||
abime((d) => { d.charge.reservations[0].table = 2; }), 'REFERENCE', 'charge.reservations[0].table'],
|
||
['siège réservé au-delà de la capacité par défaut',
|
||
abime((d) => { d.charge.reservations[1].siege = 4; }), 'REFERENCE', 'charge.reservations[1].siege'],
|
||
['siège réservé au-delà de la surcharge',
|
||
abime((d) => { d.charge.reservations[0].siege = 5; }), 'REFERENCE', 'charge.reservations[0].siege'],
|
||
['titre vers une table absente',
|
||
abime((d) => { d.charge.titres[0].table = 7; }), 'REFERENCE', 'charge.titres[0].table'],
|
||
['titre au-delà de la capacité',
|
||
abime((d) => { d.charge.titres[0].siege = 5; }), 'REFERENCE', 'charge.titres[0].siege'],
|
||
]), []);
|
||
});
|
||
});
|
||
|
||
describe('analyser : ordre des refus', () => {
|
||
test('format, forme de chaque champ, identifiants et tours, comptes, puis références ; dans chacun, l\'ordre du schéma puis des rangs', () => {
|
||
assert.deepEqual(ecartsDeRefus([
|
||
['le format avant la forme', abime((d) => {
|
||
d.entete.format = 0;
|
||
d.charge.participants[0].nom = '';
|
||
}), 'FORMAT_INCONNU', 'entete.format'],
|
||
["l'ordre du schéma dans la forme", abime((d) => {
|
||
d.charge.participants[0].nom = '';
|
||
d.charge.evenement.nom = '';
|
||
}), 'FORME', 'charge.evenement.nom'],
|
||
['les rangs dans leur ordre', abime((d) => {
|
||
d.charge.participants[2].nom = '';
|
||
d.charge.participants[1].nom = '';
|
||
}), 'FORME', 'charge.participants[1].nom'],
|
||
["un champ connu avant une clé inconnue du même objet", abime((d) => {
|
||
d.charge.participants[0].alpha = 1;
|
||
d.charge.participants[0].nom = '';
|
||
}), 'FORME', 'charge.participants[0].nom'],
|
||
['la forme de chaque champ avant les identifiants', abime((d) => {
|
||
d.charge.participants[1].id = 1;
|
||
d.charge.titres[0].libelle = '';
|
||
}), 'FORME', 'charge.titres[0].libelle'],
|
||
['la forme avant les comptes', abime((d) => {
|
||
d.entete.comptes.participants = 5;
|
||
d.charge.participants[0].nom = '';
|
||
}), 'FORME', 'charge.participants[0].nom'],
|
||
['les comptes avant les références', abime((d) => {
|
||
d.charge.participants.shift();
|
||
}), 'COMPTES', 'entete.comptes.participants'],
|
||
['la forme avant les références, même plus loin dans le texte', abime((d) => {
|
||
d.charge.reservations[0].participant = 9;
|
||
d.charge.titres[0].libelle = '';
|
||
}), 'FORME', 'charge.titres[0].libelle'],
|
||
]), []);
|
||
});
|
||
});
|
||
|
||
describe('analyser : format plus récent (§ 8.8, § 18.6)', () => {
|
||
test("un format 2 se lit, formatPlusRecent vrai, ses clés inconnues ignorées : ni l'en-tête ni la charge rendus ne les portent", () => {
|
||
const simple = analyser(abime((d) => { d.entete.format = 2; }));
|
||
assert.equal(simple.formatPlusRecent, true);
|
||
assert.equal(simple.entete.format, 2);
|
||
const { entete, charge } = documentValide();
|
||
const etendu = analyser(abime((d) => {
|
||
d.entete.format = 2;
|
||
d.annexe = { couleur: 'vert' };
|
||
d.entete.signature = null;
|
||
d.entete.comptes.lieux = 1;
|
||
d.charge.evenement.lieu = 'Salle des fêtes';
|
||
d.charge.participants[0].pronom = 'elle';
|
||
d.charge.tables[1].position.z = 0;
|
||
d.charge.propositions[0].note = 'refaite';
|
||
d.charge.propositions[0].placement[0].commentaire = 'tour calme';
|
||
}));
|
||
assert.deepEqual(etendu, {
|
||
entete: { ...entete, format: 2 },
|
||
charge,
|
||
formatPlusRecent: true,
|
||
manquants: { propositions: 0, retenu: false },
|
||
});
|
||
});
|
||
|
||
test("dans un format plus récent, toute autre faute de forme lève FORMAT_PLUS_RECENT avec le format lu ; comptes et références gardent leur raison et portent le format lu", () => {
|
||
const plusRecent = (format, modifier) => abime((d) => {
|
||
d.entete.format = format;
|
||
d.charge.evenement.lieu = 'Salle des fêtes';
|
||
modifier(d);
|
||
});
|
||
assert.deepEqual(ecartsDeRefus([
|
||
['état « archive »', plusRecent(2, (d) => { d.charge.evenement.etat = 'archive'; }),
|
||
'FORMAT_PLUS_RECENT', 'charge.evenement.etat', { format: 2 }],
|
||
['état « archive », format 3', plusRecent(3, (d) => { d.charge.evenement.etat = 'archive'; }),
|
||
'FORMAT_PLUS_RECENT', 'charge.evenement.etat', { format: 3 }],
|
||
['nom vide', plusRecent(2, (d) => { d.charge.participants[0].nom = ''; }),
|
||
'FORMAT_PLUS_RECENT', 'charge.participants[0].nom', { format: 2 }],
|
||
['clé connue absente', plusRecent(2, (d) => { delete d.charge.tables[0].forme; }),
|
||
'FORMAT_PLUS_RECENT', 'charge.tables[0].forme', { format: 2 }],
|
||
['charge qui est une liste', plusRecent(2, (d) => { d.charge = []; }),
|
||
'FORMAT_PLUS_RECENT', 'charge', { format: 2 }],
|
||
['identifiant en double', plusRecent(2, (d) => { d.charge.participants[2].id = 1; }),
|
||
'FORMAT_PLUS_RECENT', 'charge.participants[2].id', { format: 2 }],
|
||
['tour au-delà du nombre de tours', plusRecent(2, (d) => { d.charge.reservations[1].tour = 3; }),
|
||
'FORMAT_PLUS_RECENT', 'charge.reservations[1].tour', { format: 2 }],
|
||
['comptes faux', plusRecent(2, (d) => { d.entete.comptes.tables = 1; }),
|
||
'COMPTES', 'entete.comptes.tables', { format: 2 }],
|
||
['référence absente', plusRecent(2, (d) => { d.charge.titres[0].table = 7; }),
|
||
'REFERENCE', 'charge.titres[0].table', { format: 2 }],
|
||
['comptes faux, format 3', plusRecent(3, (d) => { d.entete.comptes.titres = 0; }),
|
||
'COMPTES', 'entete.comptes.titres', { format: 3 }],
|
||
['référence absente, format 3', plusRecent(3, (d) => { d.charge.reservations[1].participant = 4; }),
|
||
'REFERENCE', 'charge.reservations[1].participant', { format: 3 }],
|
||
['siège au-delà de la capacité', plusRecent(2, (d) => { d.charge.reservations[1].siege = 4; }),
|
||
'REFERENCE', 'charge.reservations[1].siege', { format: 2 }],
|
||
['au format courant, la même faute reste FORME',
|
||
abime((d) => { d.charge.evenement.etat = 'archive'; }), 'FORME', 'charge.evenement.etat'],
|
||
]), []);
|
||
});
|
||
|
||
test("une collection qu'un format plus récent ajoute, ignorée à la lecture, laisse une référence ou un compte sans objet : la raison reste REFERENCE ou COMPTES, et les détails portent le format lu", () => {
|
||
// Le format 2 de l'épreuve range une table d'appoint hors de tables, et
|
||
// une réservation la désigne ; la lecture ne garde que les clés connues.
|
||
const avecAppoint = (modifier) => abime((d) => {
|
||
d.entete.format = 2;
|
||
d.charge.tablesAppoint = [{ id: 4, numero: 3, sieges: 4, forme: 'ronde', position: { x: 0, y: 200 } }];
|
||
d.charge.prochainsIds.table = 5;
|
||
d.charge.reservations.push({ participant: 5, table: 4, siege: 1, portee: 'tous', tour: null });
|
||
d.entete.comptes.reservations = 3;
|
||
modifier(d);
|
||
});
|
||
assert.deepEqual(ecartsDeRefus([
|
||
["la réservation de la table d'appoint", avecAppoint(() => {}),
|
||
'REFERENCE', 'charge.reservations[2].table', { format: 2 }],
|
||
["la table d'appoint comptée parmi les tables", avecAppoint((d) => { d.entete.comptes.tables = 3; }),
|
||
'COMPTES', 'entete.comptes.tables', { format: 2 }],
|
||
]), []);
|
||
});
|
||
});
|
||
|
||
describe('configurationDepuisCharge (§ 6.1)', () => {
|
||
test('une table qui suit le défaut le prend, une table surchargée garde sa valeur, et changer le défaut ne touche pas la surchargée', () => {
|
||
const { charge } = documentValide();
|
||
assert.deepEqual(configurationDepuisCharge(charge).tables, [
|
||
{ id: 1, numero: 1, capacite: 3 },
|
||
{ id: 3, numero: 2, capacite: 4 },
|
||
]);
|
||
charge.evenement.siegesParDefaut = 6;
|
||
assert.deepEqual(configurationDepuisCharge(charge).tables.map((table) => table.capacite), [6, 4]);
|
||
});
|
||
|
||
test('la configuration porte les participants, les tours, les réservations sans siège et les quatre contraintes', () => {
|
||
assert.deepEqual(configurationDepuisCharge(documentValide().charge), {
|
||
participants: [
|
||
{ id: 1, nom: 'Brindille', appartenance: 'Chorale du Vallon', exclu: false },
|
||
{ id: 2, nom: 'Sarrasin', appartenance: 'Chorale du Vallon', exclu: false },
|
||
{ id: 3, nom: 'Coquelicot', appartenance: null, exclu: true },
|
||
{ id: 5, nom: 'Mirabelle', appartenance: 'Cercle Gamma', exclu: false },
|
||
],
|
||
tables: [
|
||
{ id: 1, numero: 1, capacite: 3 },
|
||
{ id: 3, numero: 2, capacite: 4 },
|
||
],
|
||
tours: 2,
|
||
reservations: [
|
||
{ participant: 1, table: 3, portee: 'tous' },
|
||
{ participant: 2, table: 1, portee: 'tour', tour: 2 },
|
||
],
|
||
contraintes: {
|
||
separerAppartenances: true,
|
||
nouveauxVoisins: false,
|
||
nouvelleTable: true,
|
||
varierAppartenances: false,
|
||
},
|
||
});
|
||
});
|
||
|
||
test("les tables vont par identifiant croissant, l'ordre du texte canonique : une charge aux tables dans le désordre et sa copie canonique donnent la même configuration, donc à graine égale le même plan (§ 8.9)", () => {
|
||
const { charge } = analyser(texteLivre('grande-sans-exception'));
|
||
const desordre = structuredClone(charge);
|
||
[desordre.tables[0], desordre.tables[1]] = [desordre.tables[1], desordre.tables[0]];
|
||
desordre.tables.push(desordre.tables.shift());
|
||
assert.notDeepEqual(desordre.tables.map(({ id }) => id), charge.tables.map(({ id }) => id));
|
||
const canonique = configurationDepuisCharge(charge);
|
||
assert.deepEqual(configurationDepuisCharge(desordre), canonique);
|
||
assert.deepEqual(
|
||
canonique.tables.map(({ id }) => id),
|
||
charge.tables.map(({ id }) => id).sort((a, b) => a - b),
|
||
);
|
||
});
|
||
|
||
test("les participants et les réservations gardent l'ordre de la charge : le rang d'une réservation que nomme une ErreurConfiguration est son rang dans la charge", () => {
|
||
const { charge } = documentValide();
|
||
charge.participants.reverse();
|
||
charge.tables.reverse();
|
||
charge.reservations.reverse();
|
||
const configuration = configurationDepuisCharge(charge);
|
||
assert.deepEqual(configuration.participants.map(({ id }) => id), [5, 3, 2, 1]);
|
||
assert.deepEqual(configuration.tables.map(({ id }) => id), [1, 3]);
|
||
assert.deepEqual(configuration.reservations.map(({ participant }) => participant), [2, 1]);
|
||
// La seconde réservation de la charge désigne une table absente : le
|
||
// moteur la nomme à son rang dans la charge, 1.
|
||
charge.reservations[1].table = 9;
|
||
assert.throws(
|
||
() => normaliser(configurationDepuisCharge(charge)),
|
||
(erreur) =>
|
||
erreur instanceof ErreurConfiguration &&
|
||
erreur.code === 'RESERVATION_INCONNUE' &&
|
||
erreur.details.reservation === 1 &&
|
||
erreur.details.table === 9,
|
||
);
|
||
});
|
||
|
||
test('le résultat passe normaliser du moteur : exclu écarté, ancrage dérivé de la portée « tous », capacités résolues', () => {
|
||
const charge = geler(documentValide().charge);
|
||
const instance = normaliser(configurationDepuisCharge(charge));
|
||
assert.deepEqual(instance.ids, [1, 2, 5]);
|
||
assert.deepEqual([...instance.exclus], [3]);
|
||
assert.deepEqual(instance.idsTables, [1, 3]);
|
||
assert.deepEqual(instance.capacite, Int32Array.from([3, 4]));
|
||
assert.deepEqual(instance.statut, Uint8Array.from([STATUT.ANCRE, STATUT.PARTIELLEMENT_FIXE, STATUT.MOBILE]));
|
||
assert.equal(instance.R, 2);
|
||
});
|
||
});
|
||
|
||
describe('etatDeduit (§ 8.7, § 9)', () => {
|
||
test('sans proposition, brouillon ; avec, proposé ; avec un retenu, retenu', () => {
|
||
assert.equal(etatDeduit(creerCharge({ id: 'evt-neuf', nom: 'Atelier', siegesParDefaut: 8, tours: 3 })), 'brouillon');
|
||
const { charge } = documentValide();
|
||
assert.equal(etatDeduit(charge), 'propose');
|
||
charge.retenu = retenuValide();
|
||
assert.equal(etatDeduit(charge), 'retenu');
|
||
charge.propositions = [];
|
||
assert.equal(etatDeduit(charge), 'retenu');
|
||
});
|
||
|
||
test("un plan bloqué donne l'un des trois, jamais bloqué : le contenu décide, pas l'état inscrit", () => {
|
||
const bloquee = (modifier) => {
|
||
const { charge } = documentValide();
|
||
charge.evenement.etat = 'bloque';
|
||
modifier(charge);
|
||
return etatDeduit(charge);
|
||
};
|
||
assert.deepEqual(
|
||
[
|
||
bloquee((c) => { c.propositions = []; }),
|
||
bloquee(() => {}),
|
||
bloquee((c) => { c.retenu = retenuValide(); }),
|
||
],
|
||
['brouillon', 'propose', 'retenu'],
|
||
);
|
||
const { charge } = documentValide();
|
||
charge.evenement.etat = 'retenu';
|
||
charge.propositions = [];
|
||
assert.equal(etatDeduit(charge), 'brouillon');
|
||
});
|
||
});
|
||
|
||
describe('capacite (§ 6.1)', () => {
|
||
test("les sièges de la table, ou le défaut quand elle le suit ; un identifiant au lieu de l'enregistrement lève TypeError", () => {
|
||
const { charge } = documentValide();
|
||
assert.equal(capacite(charge, charge.tables[0]), 3);
|
||
assert.equal(capacite(charge, charge.tables[1]), 4);
|
||
charge.evenement.siegesParDefaut = 10;
|
||
assert.equal(capacite(charge, charge.tables[0]), 10);
|
||
assert.equal(capacite(charge, charge.tables[1]), 4);
|
||
assert.throws(() => capacite(charge, 3), TypeError);
|
||
assert.throws(() => capacite(charge, null), TypeError);
|
||
});
|
||
});
|
||
|
||
describe('premiereFaute : le parcours de forme, sans lever (§ 8.8, § 8.9)', () => {
|
||
test("le chemin de la première faute, ou null ; une règle aPart n'examine que son conteneur sous une autre règle, et tout son contenu donnée pour racine", () => {
|
||
const regleDe = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
|
||
const RETENU = regleDe(regleDe(SCHEMA, 'charge'), 'retenu');
|
||
const valide = documentValide();
|
||
valide.charge.retenu = retenuValide();
|
||
assert.equal(premiereFaute(valide, SCHEMA), null);
|
||
assert.equal(premiereFaute(valide.charge.retenu, RETENU), null);
|
||
const fautif = { ...retenuValide(), tours: 0 };
|
||
valide.charge.retenu = fautif;
|
||
assert.equal(premiereFaute(valide, SCHEMA), null);
|
||
assert.equal(premiereFaute(fautif, RETENU), 'tours');
|
||
assert.equal(premiereFaute(fautif, RETENU, 'charge.retenu'), 'charge.retenu.tours');
|
||
assert.equal(premiereFaute(null, RETENU), null);
|
||
valide.charge.retenu = [];
|
||
assert.equal(premiereFaute(valide, SCHEMA), 'charge.retenu');
|
||
});
|
||
|
||
test("premiereFauteEnPlace lit une valeur comme l'analyse la lit à sa place : une règle aPart n'y examine que son conteneur, toute autre comme premiereFaute", () => {
|
||
const regleDe = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
|
||
const CHARGE = regleDe(SCHEMA, 'charge');
|
||
const RETENU = regleDe(CHARGE, 'retenu');
|
||
const PROPOSITIONS = regleDe(CHARGE, 'propositions');
|
||
const EVENEMENT = regleDe(CHARGE, 'evenement');
|
||
const fautif = { ...retenuValide(), tours: 0 };
|
||
assert.equal(premiereFaute(fautif, RETENU), 'tours');
|
||
assert.equal(premiereFauteEnPlace(fautif, RETENU), null);
|
||
assert.equal(premiereFauteEnPlace(null, RETENU), null);
|
||
assert.equal(premiereFauteEnPlace([], RETENU), '');
|
||
assert.equal(premiereFauteEnPlace(5, RETENU, 'charge.retenu'), 'charge.retenu');
|
||
assert.equal(premiereFaute([42], PROPOSITIONS), '[0]');
|
||
assert.equal(premiereFauteEnPlace([42, { id: 'x' }], PROPOSITIONS), null);
|
||
assert.equal(premiereFauteEnPlace({}, PROPOSITIONS), '');
|
||
assert.equal(premiereFauteEnPlace(null, PROPOSITIONS), '');
|
||
const { evenement } = documentValide().charge;
|
||
for (const valeur of [evenement, { ...evenement, nom: '' }, { ...evenement, lieu: 'Salle' }, 5]) {
|
||
assert.equal(premiereFauteEnPlace(valeur, EVENEMENT), premiereFaute(valeur, EVENEMENT), JSON.stringify(valeur));
|
||
}
|
||
assert.equal(premiereFauteEnPlace({ ...evenement, nom: '' }, EVENEMENT, 'charge.evenement'), 'charge.evenement.nom');
|
||
});
|
||
});
|