gestion_table_tournante_libre/src/stockage/document.test.js

1029 lines
52 KiB
JavaScript
Raw Normal View History

[ADD] storage: event document, canonical form, consistency check An event lives in one state file: a header (format, writing build, revision, counts) and a payload. The serializer orders keys by one schema table and records by id, sorts the sets — reserves, placed participants, unattributed seats — and reads no clock nor randomness: one state gives one text, byte for byte. Reading refuses an empty, truncated or self-contradicting file by reason and path, reads a newer format with its unknown keys ignored, never refuses a whole file for one proposition. The engine exports its default history length. Checked: 58 tests and the golden text; two reviews, mutants killed. --- FR --- [ADD] stockage : document d'événement, forme canonique, contrôle Un événement vit dans un fichier d'état : un en-tête (format, construction qui écrit, révision, comptes) et une charge. Le sérialiseur range les clés par une seule table de schéma et les enregistrements par identifiant, trie les ensembles — réserves, participants placés, sièges non attribués — et ne lit ni horloge ni aléa : un état, un texte, octet pour octet. La lecture refuse un fichier vide, tronqué ou contradictoire par raison et chemin, lit un format plus récent en ignorant ses clés inconnues, et ne refuse jamais un fichier pour une proposition. Vérifié : 58 épreuves et le texte exact ; deux revues, mutants tués. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 13:11:34 -04:00
// © 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';
[FIX] storage, session: strict UTF-8, lost journal, lock, exclusions The whole-iteration review found ways to lose or corrupt data. A state saved in another encoding opened with its accents turned to U+FFFD and was then overwritten; decoding is now strict, the state opens read-only, and a gesture from its secours first moves it to the trash. A journal decodes line by line, so one bad byte drops only the lines after it. A vanished journal is rewritten whole, never appended to. Deleting refuses an event another session holds; switching to writing rereads the disk; an import that changes exclu says which reservations it suspends. Checked: 1504 node, 35 browser, 29 node-long; depot.js, journal.js 100 %. --- FR --- [FIX] stockage, séance : UTF-8 strict, journal perdu, verrou, exclusions La revue d'ensemble a trouvé des façons de perdre ou d'abîmer des données. Un état enregistré dans un autre encodage s'ouvrait, ses accents changés en U+FFFD, puis s'écrasait ; le décodage est strict, l'état s'ouvre en lecture seule, et un geste depuis son secours le range d'abord à la corbeille. Le journal se décode ligne par ligne : un octet fautif n'écarte que la suite. Un journal disparu se réécrit en entier, jamais par ajout. Supprimer refuse un événement qu'une autre séance tient ; passer en écriture relit le disque ; un import qui change exclu dit quelles réservations il suspend. Vérifié : 1504 node, 35 navigateur, 29 node-long ; depot.js, journal.js 100 %. Assisted-by: Claude Opus 5.5
2026-10-07 01:20:42 -04:00
import { texteLivre } from '../demo/livrees.js';
[ADD] storage: event document, canonical form, consistency check An event lives in one state file: a header (format, writing build, revision, counts) and a payload. The serializer orders keys by one schema table and records by id, sorts the sets — reserves, placed participants, unattributed seats — and reads no clock nor randomness: one state gives one text, byte for byte. Reading refuses an empty, truncated or self-contradicting file by reason and path, reads a newer format with its unknown keys ignored, never refuses a whole file for one proposition. The engine exports its default history length. Checked: 58 tests and the golden text; two reviews, mutants killed. --- FR --- [ADD] stockage : document d'événement, forme canonique, contrôle Un événement vit dans un fichier d'état : un en-tête (format, construction qui écrit, révision, comptes) et une charge. Le sérialiseur range les clés par une seule table de schéma et les enregistrements par identifiant, trie les ensembles — réserves, participants placés, sièges non attribués — et ne lit ni horloge ni aléa : un état, un texte, octet pour octet. La lecture refuse un fichier vide, tronqué ou contradictoire par raison et chemin, lit un format plus récent en ignorant ses clés inconnues, et ne refuse jamais un fichier pour une proposition. Vérifié : 58 épreuves et le texte exact ; deux revues, mutants tués. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 13:11:34 -04:00
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 {}');
});
[FIX] storage, session: strict UTF-8, lost journal, lock, exclusions The whole-iteration review found ways to lose or corrupt data. A state saved in another encoding opened with its accents turned to U+FFFD and was then overwritten; decoding is now strict, the state opens read-only, and a gesture from its secours first moves it to the trash. A journal decodes line by line, so one bad byte drops only the lines after it. A vanished journal is rewritten whole, never appended to. Deleting refuses an event another session holds; switching to writing rereads the disk; an import that changes exclu says which reservations it suspends. Checked: 1504 node, 35 browser, 29 node-long; depot.js, journal.js 100 %. --- FR --- [FIX] stockage, séance : UTF-8 strict, journal perdu, verrou, exclusions La revue d'ensemble a trouvé des façons de perdre ou d'abîmer des données. Un état enregistré dans un autre encodage s'ouvrait, ses accents changés en U+FFFD, puis s'écrasait ; le décodage est strict, l'état s'ouvre en lecture seule, et un geste depuis son secours le range d'abord à la corbeille. Le journal se décode ligne par ligne : un octet fautif n'écarte que la suite. Un journal disparu se réécrit en entier, jamais par ajout. Supprimer refuse un événement qu'une autre séance tient ; passer en écriture relit le disque ; un import qui change exclu dit quelles réservations il suspend. Vérifié : 1504 node, 35 navigateur, 29 node-long ; depot.js, journal.js 100 %. Assisted-by: Claude Opus 5.5
2026-10-07 01:20:42 -04:00
test("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"}',
);
});
[ADD] storage: event document, canonical form, consistency check An event lives in one state file: a header (format, writing build, revision, counts) and a payload. The serializer orders keys by one schema table and records by id, sorts the sets — reserves, placed participants, unattributed seats — and reads no clock nor randomness: one state gives one text, byte for byte. Reading refuses an empty, truncated or self-contradicting file by reason and path, reads a newer format with its unknown keys ignored, never refuses a whole file for one proposition. The engine exports its default history length. Checked: 58 tests and the golden text; two reviews, mutants killed. --- FR --- [ADD] stockage : document d'événement, forme canonique, contrôle Un événement vit dans un fichier d'état : un en-tête (format, construction qui écrit, révision, comptes) et une charge. Le sérialiseur range les clés par une seule table de schéma et les enregistrements par identifiant, trie les ensembles — réserves, participants placés, sièges non attribués — et ne lit ni horloge ni aléa : un état, un texte, octet pour octet. La lecture refuse un fichier vide, tronqué ou contradictoire par raison et chemin, lit un format plus récent en ignorant ses clés inconnues, et ne refuse jamais un fichier pour une proposition. Vérifié : 58 épreuves et le texte exact ; deux revues, mutants tués. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 13:11:34 -04:00
});
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();
[FIX] storage, session: strict UTF-8, lost journal, lock, exclusions The whole-iteration review found ways to lose or corrupt data. A state saved in another encoding opened with its accents turned to U+FFFD and was then overwritten; decoding is now strict, the state opens read-only, and a gesture from its secours first moves it to the trash. A journal decodes line by line, so one bad byte drops only the lines after it. A vanished journal is rewritten whole, never appended to. Deleting refuses an event another session holds; switching to writing rereads the disk; an import that changes exclu says which reservations it suspends. Checked: 1504 node, 35 browser, 29 node-long; depot.js, journal.js 100 %. --- FR --- [FIX] stockage, séance : UTF-8 strict, journal perdu, verrou, exclusions La revue d'ensemble a trouvé des façons de perdre ou d'abîmer des données. Un état enregistré dans un autre encodage s'ouvrait, ses accents changés en U+FFFD, puis s'écrasait ; le décodage est strict, l'état s'ouvre en lecture seule, et un geste depuis son secours le range d'abord à la corbeille. Le journal se décode ligne par ligne : un octet fautif n'écarte que la suite. Un journal disparu se réécrit en entier, jamais par ajout. Supprimer refuse un événement qu'une autre séance tient ; passer en écriture relit le disque ; un import qui change exclu dit quelles réservations il suspend. Vérifié : 1504 node, 35 navigateur, 29 node-long ; depot.js, journal.js 100 %. Assisted-by: Claude Opus 5.5
2026-10-07 01:20:42 -04:00
assert.deepEqual(analyser(JSON.stringify(documentValide())), {
entete,
charge,
formatPlusRecent: false,
manquants: { propositions: 0, retenu: false },
});
[ADD] storage: event document, canonical form, consistency check An event lives in one state file: a header (format, writing build, revision, counts) and a payload. The serializer orders keys by one schema table and records by id, sorts the sets — reserves, placed participants, unattributed seats — and reads no clock nor randomness: one state gives one text, byte for byte. Reading refuses an empty, truncated or self-contradicting file by reason and path, reads a newer format with its unknown keys ignored, never refuses a whole file for one proposition. The engine exports its default history length. Checked: 58 tests and the golden text; two reviews, mutants killed. --- FR --- [ADD] stockage : document d'événement, forme canonique, contrôle Un événement vit dans un fichier d'état : un en-tête (format, construction qui écrit, révision, comptes) et une charge. Le sérialiseur range les clés par une seule table de schéma et les enregistrements par identifiant, trie les ensembles — réserves, participants placés, sièges non attribués — et ne lit ni horloge ni aléa : un état, un texte, octet pour octet. La lecture refuse un fichier vide, tronqué ou contradictoire par raison et chemin, lit un format plus récent en ignorant ses clés inconnues, et ne refuse jamais un fichier pour une proposition. Vérifié : 58 épreuves et le texte exact ; deux revues, mutants tués. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 13:11:34 -04:00
});
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) => {
2026-10-06 19:22:02 -04:00
d.charge.propositions.push(null, { id: 'neuf' }, { id: 9.5 }, { id: 2 ** 31 }, { id: 2 ** 53 }, [9]);
d.entete.comptes.propositions = 7;
[ADD] storage: event document, canonical form, consistency check An event lives in one state file: a header (format, writing build, revision, counts) and a payload. The serializer orders keys by one schema table and records by id, sorts the sets — reserves, placed participants, unattributed seats — and reads no clock nor randomness: one state gives one text, byte for byte. Reading refuses an empty, truncated or self-contradicting file by reason and path, reads a newer format with its unknown keys ignored, never refuses a whole file for one proposition. The engine exports its default history length. Checked: 58 tests and the golden text; two reviews, mutants killed. --- FR --- [ADD] stockage : document d'événement, forme canonique, contrôle Un événement vit dans un fichier d'état : un en-tête (format, construction qui écrit, révision, comptes) et une charge. Le sérialiseur range les clés par une seule table de schéma et les enregistrements par identifiant, trie les ensembles — réserves, participants placés, sièges non attribués — et ne lit ni horloge ni aléa : un état, un texte, octet pour octet. La lecture refuse un fichier vide, tronqué ou contradictoire par raison et chemin, lit un format plus récent en ignorant ses clés inconnues, et ne refuse jamais un fichier pour une proposition. Vérifié : 58 épreuves et le texte exact ; deux revues, mutants tués. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 13:11:34 -04:00
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;
})],
2026-10-06 19:22:02 -04:00
['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;
})],
[ADD] storage: event document, canonical form, consistency check An event lives in one state file: a header (format, writing build, revision, counts) and a payload. The serializer orders keys by one schema table and records by id, sorts the sets — reserves, placed participants, unattributed seats — and reads no clock nor randomness: one state gives one text, byte for byte. Reading refuses an empty, truncated or self-contradicting file by reason and path, reads a newer format with its unknown keys ignored, never refuses a whole file for one proposition. The engine exports its default history length. Checked: 58 tests and the golden text; two reviews, mutants killed. --- FR --- [ADD] stockage : document d'événement, forme canonique, contrôle Un événement vit dans un fichier d'état : un en-tête (format, construction qui écrit, révision, comptes) et une charge. Le sérialiseur range les clés par une seule table de schéma et les enregistrements par identifiant, trie les ensembles — réserves, participants placés, sièges non attribués — et ne lit ni horloge ni aléa : un état, un texte, octet pour octet. La lecture refuse un fichier vide, tronqué ou contradictoire par raison et chemin, lit un format plus récent en ignorant ses clés inconnues, et ne refuse jamais un fichier pour une proposition. Vérifié : 58 épreuves et le texte exact ; deux revues, mutants tués. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 13:11:34 -04:00
['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'],
2026-10-06 19:22:02 -04:00
// 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'],
[ADD] storage: event document, canonical form, consistency check An event lives in one state file: a header (format, writing build, revision, counts) and a payload. The serializer orders keys by one schema table and records by id, sorts the sets — reserves, placed participants, unattributed seats — and reads no clock nor randomness: one state gives one text, byte for byte. Reading refuses an empty, truncated or self-contradicting file by reason and path, reads a newer format with its unknown keys ignored, never refuses a whole file for one proposition. The engine exports its default history length. Checked: 58 tests and the golden text; two reviews, mutants killed. --- FR --- [ADD] stockage : document d'événement, forme canonique, contrôle Un événement vit dans un fichier d'état : un en-tête (format, construction qui écrit, révision, comptes) et une charge. Le sérialiseur range les clés par une seule table de schéma et les enregistrements par identifiant, trie les ensembles — réserves, participants placés, sièges non attribués — et ne lit ni horloge ni aléa : un état, un texte, octet pour octet. La lecture refuse un fichier vide, tronqué ou contradictoire par raison et chemin, lit un format plus récent en ignorant ses clés inconnues, et ne refuse jamais un fichier pour une proposition. Vérifié : 58 épreuves et le texte exact ; deux revues, mutants tués. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 13:11:34 -04:00
['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 {
[FIX] storage, session: strict UTF-8, lost journal, lock, exclusions The whole-iteration review found ways to lose or corrupt data. A state saved in another encoding opened with its accents turned to U+FFFD and was then overwritten; decoding is now strict, the state opens read-only, and a gesture from its secours first moves it to the trash. A journal decodes line by line, so one bad byte drops only the lines after it. A vanished journal is rewritten whole, never appended to. Deleting refuses an event another session holds; switching to writing rereads the disk; an import that changes exclu says which reservations it suspends. Checked: 1504 node, 35 browser, 29 node-long; depot.js, journal.js 100 %. --- FR --- [FIX] stockage, séance : UTF-8 strict, journal perdu, verrou, exclusions La revue d'ensemble a trouvé des façons de perdre ou d'abîmer des données. Un état enregistré dans un autre encodage s'ouvrait, ses accents changés en U+FFFD, puis s'écrasait ; le décodage est strict, l'état s'ouvre en lecture seule, et un geste depuis son secours le range d'abord à la corbeille. Le journal se décode ligne par ligne : un octet fautif n'écarte que la suite. Un journal disparu se réécrit en entier, jamais par ajout. Supprimer refuse un événement qu'une autre séance tient ; passer en écriture relit le disque ; un import qui change exclu dit quelles réservations il suspend. Vérifié : 1504 node, 35 navigateur, 29 node-long ; depot.js, journal.js 100 %. Assisted-by: Claude Opus 5.5
2026-10-07 01:20:42 -04:00
assert.deepEqual(analyser(JSON.stringify(attendu)), {
...attendu,
formatPlusRecent: format > FORMAT,
manquants: { propositions: 0, retenu: false },
});
[ADD] storage: event document, canonical form, consistency check An event lives in one state file: a header (format, writing build, revision, counts) and a payload. The serializer orders keys by one schema table and records by id, sorts the sets — reserves, placed participants, unattributed seats — and reads no clock nor randomness: one state gives one text, byte for byte. Reading refuses an empty, truncated or self-contradicting file by reason and path, reads a newer format with its unknown keys ignored, never refuses a whole file for one proposition. The engine exports its default history length. Checked: 58 tests and the golden text; two reviews, mutants killed. --- FR --- [ADD] stockage : document d'événement, forme canonique, contrôle Un événement vit dans un fichier d'état : un en-tête (format, construction qui écrit, révision, comptes) et une charge. Le sérialiseur range les clés par une seule table de schéma et les enregistrements par identifiant, trie les ensembles — réserves, participants placés, sièges non attribués — et ne lit ni horloge ni aléa : un état, un texte, octet pour octet. La lecture refuse un fichier vide, tronqué ou contradictoire par raison et chemin, lit un format plus récent en ignorant ses clés inconnues, et ne refuse jamais un fichier pour une proposition. Vérifié : 58 épreuves et le texte exact ; deux revues, mutants tués. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 13:11:34 -04:00
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)', () => {
[FIX] storage, session: strict UTF-8, lost journal, lock, exclusions The whole-iteration review found ways to lose or corrupt data. A state saved in another encoding opened with its accents turned to U+FFFD and was then overwritten; decoding is now strict, the state opens read-only, and a gesture from its secours first moves it to the trash. A journal decodes line by line, so one bad byte drops only the lines after it. A vanished journal is rewritten whole, never appended to. Deleting refuses an event another session holds; switching to writing rereads the disk; an import that changes exclu says which reservations it suspends. Checked: 1504 node, 35 browser, 29 node-long; depot.js, journal.js 100 %. --- FR --- [FIX] stockage, séance : UTF-8 strict, journal perdu, verrou, exclusions La revue d'ensemble a trouvé des façons de perdre ou d'abîmer des données. Un état enregistré dans un autre encodage s'ouvrait, ses accents changés en U+FFFD, puis s'écrasait ; le décodage est strict, l'état s'ouvre en lecture seule, et un geste depuis son secours le range d'abord à la corbeille. Le journal se décode ligne par ligne : un octet fautif n'écarte que la suite. Un journal disparu se réécrit en entier, jamais par ajout. Supprimer refuse un événement qu'une autre séance tient ; passer en écriture relit le disque ; un import qui change exclu dit quelles réservations il suspend. Vérifié : 1504 node, 35 navigateur, 29 node-long ; depot.js, journal.js 100 %. Assisted-by: Claude Opus 5.5
2026-10-07 01:20:42 -04:00
test('les comptes de la saisie — participants, tables, réservations, titres — se comparent à leur liste et refusent le fichier', () => {
[ADD] storage: event document, canonical form, consistency check An event lives in one state file: a header (format, writing build, revision, counts) and a payload. The serializer orders keys by one schema table and records by id, sorts the sets — reserves, placed participants, unattributed seats — and reads no clock nor randomness: one state gives one text, byte for byte. Reading refuses an empty, truncated or self-contradicting file by reason and path, reads a newer format with its unknown keys ignored, never refuses a whole file for one proposition. The engine exports its default history length. Checked: 58 tests and the golden text; two reviews, mutants killed. --- FR --- [ADD] stockage : document d'événement, forme canonique, contrôle Un événement vit dans un fichier d'état : un en-tête (format, construction qui écrit, révision, comptes) et une charge. Le sérialiseur range les clés par une seule table de schéma et les enregistrements par identifiant, trie les ensembles — réserves, participants placés, sièges non attribués — et ne lit ni horloge ni aléa : un état, un texte, octet pour octet. La lecture refuse un fichier vide, tronqué ou contradictoire par raison et chemin, lit un format plus récent en ignorant ses clés inconnues, et ne refuse jamais un fichier pour une proposition. Vérifié : 58 épreuves et le texte exact ; deux revues, mutants tués. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 13:11:34 -04:00
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'],
]), []);
});
[FIX] storage, session: strict UTF-8, lost journal, lock, exclusions The whole-iteration review found ways to lose or corrupt data. A state saved in another encoding opened with its accents turned to U+FFFD and was then overwritten; decoding is now strict, the state opens read-only, and a gesture from its secours first moves it to the trash. A journal decodes line by line, so one bad byte drops only the lines after it. A vanished journal is rewritten whole, never appended to. Deleting refuses an event another session holds; switching to writing rereads the disk; an import that changes exclu says which reservations it suspends. Checked: 1504 node, 35 browser, 29 node-long; depot.js, journal.js 100 %. --- FR --- [FIX] stockage, séance : UTF-8 strict, journal perdu, verrou, exclusions La revue d'ensemble a trouvé des façons de perdre ou d'abîmer des données. Un état enregistré dans un autre encodage s'ouvrait, ses accents changés en U+FFFD, puis s'écrasait ; le décodage est strict, l'état s'ouvre en lecture seule, et un geste depuis son secours le range d'abord à la corbeille. Le journal se décode ligne par ligne : un octet fautif n'écarte que la suite. Un journal disparu se réécrit en entier, jamais par ajout. Supprimer refuse un événement qu'une autre séance tient ; passer en écriture relit le disque ; un import qui change exclu dit quelles réservations il suspend. Vérifié : 1504 node, 35 navigateur, 29 node-long ; depot.js, journal.js 100 %. Assisted-by: Claude Opus 5.5
2026-10-07 01:20:42 -04:00
test("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 });
});
[ADD] storage: event document, canonical form, consistency check An event lives in one state file: a header (format, writing build, revision, counts) and a payload. The serializer orders keys by one schema table and records by id, sorts the sets — reserves, placed participants, unattributed seats — and reads no clock nor randomness: one state gives one text, byte for byte. Reading refuses an empty, truncated or self-contradicting file by reason and path, reads a newer format with its unknown keys ignored, never refuses a whole file for one proposition. The engine exports its default history length. Checked: 58 tests and the golden text; two reviews, mutants killed. --- FR --- [ADD] stockage : document d'événement, forme canonique, contrôle Un événement vit dans un fichier d'état : un en-tête (format, construction qui écrit, révision, comptes) et une charge. Le sérialiseur range les clés par une seule table de schéma et les enregistrements par identifiant, trie les ensembles — réserves, participants placés, sièges non attribués — et ne lit ni horloge ni aléa : un état, un texte, octet pour octet. La lecture refuse un fichier vide, tronqué ou contradictoire par raison et chemin, lit un format plus récent en ignorant ses clés inconnues, et ne refuse jamais un fichier pour une proposition. Vérifié : 58 épreuves et le texte exact ; deux revues, mutants tués. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 13:11:34 -04:00
});
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';
}));
[FIX] storage, session: strict UTF-8, lost journal, lock, exclusions The whole-iteration review found ways to lose or corrupt data. A state saved in another encoding opened with its accents turned to U+FFFD and was then overwritten; decoding is now strict, the state opens read-only, and a gesture from its secours first moves it to the trash. A journal decodes line by line, so one bad byte drops only the lines after it. A vanished journal is rewritten whole, never appended to. Deleting refuses an event another session holds; switching to writing rereads the disk; an import that changes exclu says which reservations it suspends. Checked: 1504 node, 35 browser, 29 node-long; depot.js, journal.js 100 %. --- FR --- [FIX] stockage, séance : UTF-8 strict, journal perdu, verrou, exclusions La revue d'ensemble a trouvé des façons de perdre ou d'abîmer des données. Un état enregistré dans un autre encodage s'ouvrait, ses accents changés en U+FFFD, puis s'écrasait ; le décodage est strict, l'état s'ouvre en lecture seule, et un geste depuis son secours le range d'abord à la corbeille. Le journal se décode ligne par ligne : un octet fautif n'écarte que la suite. Un journal disparu se réécrit en entier, jamais par ajout. Supprimer refuse un événement qu'une autre séance tient ; passer en écriture relit le disque ; un import qui change exclu dit quelles réservations il suspend. Vérifié : 1504 node, 35 navigateur, 29 node-long ; depot.js, journal.js 100 %. Assisted-by: Claude Opus 5.5
2026-10-07 01:20:42 -04:00
assert.deepEqual(etendu, {
entete: { ...entete, format: 2 },
charge,
formatPlusRecent: true,
manquants: { propositions: 0, retenu: false },
});
[ADD] storage: event document, canonical form, consistency check An event lives in one state file: a header (format, writing build, revision, counts) and a payload. The serializer orders keys by one schema table and records by id, sorts the sets — reserves, placed participants, unattributed seats — and reads no clock nor randomness: one state gives one text, byte for byte. Reading refuses an empty, truncated or self-contradicting file by reason and path, reads a newer format with its unknown keys ignored, never refuses a whole file for one proposition. The engine exports its default history length. Checked: 58 tests and the golden text; two reviews, mutants killed. --- FR --- [ADD] stockage : document d'événement, forme canonique, contrôle Un événement vit dans un fichier d'état : un en-tête (format, construction qui écrit, révision, comptes) et une charge. Le sérialiseur range les clés par une seule table de schéma et les enregistrements par identifiant, trie les ensembles — réserves, participants placés, sièges non attribués — et ne lit ni horloge ni aléa : un état, un texte, octet pour octet. La lecture refuse un fichier vide, tronqué ou contradictoire par raison et chemin, lit un format plus récent en ignorant ses clés inconnues, et ne refuse jamais un fichier pour une proposition. Vérifié : 58 épreuves et le texte exact ; deux revues, mutants tués. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 13:11:34 -04:00
});
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,
},
});
});
[FIX] storage, session: strict UTF-8, lost journal, lock, exclusions The whole-iteration review found ways to lose or corrupt data. A state saved in another encoding opened with its accents turned to U+FFFD and was then overwritten; decoding is now strict, the state opens read-only, and a gesture from its secours first moves it to the trash. A journal decodes line by line, so one bad byte drops only the lines after it. A vanished journal is rewritten whole, never appended to. Deleting refuses an event another session holds; switching to writing rereads the disk; an import that changes exclu says which reservations it suspends. Checked: 1504 node, 35 browser, 29 node-long; depot.js, journal.js 100 %. --- FR --- [FIX] stockage, séance : UTF-8 strict, journal perdu, verrou, exclusions La revue d'ensemble a trouvé des façons de perdre ou d'abîmer des données. Un état enregistré dans un autre encodage s'ouvrait, ses accents changés en U+FFFD, puis s'écrasait ; le décodage est strict, l'état s'ouvre en lecture seule, et un geste depuis son secours le range d'abord à la corbeille. Le journal se décode ligne par ligne : un octet fautif n'écarte que la suite. Un journal disparu se réécrit en entier, jamais par ajout. Supprimer refuse un événement qu'une autre séance tient ; passer en écriture relit le disque ; un import qui change exclu dit quelles réservations il suspend. Vérifié : 1504 node, 35 navigateur, 29 node-long ; depot.js, journal.js 100 %. Assisted-by: Claude Opus 5.5
2026-10-07 01:20:42 -04:00
test("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", () => {
[ADD] storage: event document, canonical form, consistency check An event lives in one state file: a header (format, writing build, revision, counts) and a payload. The serializer orders keys by one schema table and records by id, sorts the sets — reserves, placed participants, unattributed seats — and reads no clock nor randomness: one state gives one text, byte for byte. Reading refuses an empty, truncated or self-contradicting file by reason and path, reads a newer format with its unknown keys ignored, never refuses a whole file for one proposition. The engine exports its default history length. Checked: 58 tests and the golden text; two reviews, mutants killed. --- FR --- [ADD] stockage : document d'événement, forme canonique, contrôle Un événement vit dans un fichier d'état : un en-tête (format, construction qui écrit, révision, comptes) et une charge. Le sérialiseur range les clés par une seule table de schéma et les enregistrements par identifiant, trie les ensembles — réserves, participants placés, sièges non attribués — et ne lit ni horloge ni aléa : un état, un texte, octet pour octet. La lecture refuse un fichier vide, tronqué ou contradictoire par raison et chemin, lit un format plus récent en ignorant ses clés inconnues, et ne refuse jamais un fichier pour une proposition. Vérifié : 58 épreuves et le texte exact ; deux revues, mutants tués. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 13:11:34 -04:00
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]);
[FIX] storage, session: strict UTF-8, lost journal, lock, exclusions The whole-iteration review found ways to lose or corrupt data. A state saved in another encoding opened with its accents turned to U+FFFD and was then overwritten; decoding is now strict, the state opens read-only, and a gesture from its secours first moves it to the trash. A journal decodes line by line, so one bad byte drops only the lines after it. A vanished journal is rewritten whole, never appended to. Deleting refuses an event another session holds; switching to writing rereads the disk; an import that changes exclu says which reservations it suspends. Checked: 1504 node, 35 browser, 29 node-long; depot.js, journal.js 100 %. --- FR --- [FIX] stockage, séance : UTF-8 strict, journal perdu, verrou, exclusions La revue d'ensemble a trouvé des façons de perdre ou d'abîmer des données. Un état enregistré dans un autre encodage s'ouvrait, ses accents changés en U+FFFD, puis s'écrasait ; le décodage est strict, l'état s'ouvre en lecture seule, et un geste depuis son secours le range d'abord à la corbeille. Le journal se décode ligne par ligne : un octet fautif n'écarte que la suite. Un journal disparu se réécrit en entier, jamais par ajout. Supprimer refuse un événement qu'une autre séance tient ; passer en écriture relit le disque ; un import qui change exclu dit quelles réservations il suspend. Vérifié : 1504 node, 35 navigateur, 29 node-long ; depot.js, journal.js 100 %. Assisted-by: Claude Opus 5.5
2026-10-07 01:20:42 -04:00
assert.deepEqual(configuration.tables.map(({ id }) => id), [1, 3]);
[ADD] storage: event document, canonical form, consistency check An event lives in one state file: a header (format, writing build, revision, counts) and a payload. The serializer orders keys by one schema table and records by id, sorts the sets — reserves, placed participants, unattributed seats — and reads no clock nor randomness: one state gives one text, byte for byte. Reading refuses an empty, truncated or self-contradicting file by reason and path, reads a newer format with its unknown keys ignored, never refuses a whole file for one proposition. The engine exports its default history length. Checked: 58 tests and the golden text; two reviews, mutants killed. --- FR --- [ADD] stockage : document d'événement, forme canonique, contrôle Un événement vit dans un fichier d'état : un en-tête (format, construction qui écrit, révision, comptes) et une charge. Le sérialiseur range les clés par une seule table de schéma et les enregistrements par identifiant, trie les ensembles — réserves, participants placés, sièges non attribués — et ne lit ni horloge ni aléa : un état, un texte, octet pour octet. La lecture refuse un fichier vide, tronqué ou contradictoire par raison et chemin, lit un format plus récent en ignorant ses clés inconnues, et ne refuse jamais un fichier pour une proposition. Vérifié : 58 épreuves et le texte exact ; deux revues, mutants tués. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 13:11:34 -04:00
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');
});
});